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) {
47 // happened — it also covers "no target exists", which is routine and not an47 // happened — it also covers "no target exists", which is routine and not an
48 // error. Only a failure to reach a target that does exist is a genuine error.48 // error. Only a failure to reach a target that does exist is a genuine error.
49 func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {49 func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
50+ return d.jumpAttentionForRole(sess, ac, attachmentRoleToken{})
51+}
52+
53+func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token attachmentRoleToken) error {
50 if sess == nil || ac == nil {54 if sess == nil || ac == nil {
51 return nil55 return nil
52 }56 }
@@ -62,7 +66,11 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
62 if !ok {66 if !ok {
63 return nil67 return nil
64 }68 }
65- return d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex})69+ pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}
70+ if token.ac == nil {
71+ return d.switchToTarget(sess, ac, pickerTarget)
72+ }
73+ return d.switchActiveTargetForRole(token, pickerTarget)
66 }74 }
67 75
68 func oldestAttentionTab(sess *session) (int, bool) {76 func oldestAttentionTab(sess *session) (int, bool) {
M internal/usecase/daemon/client.go +515 -205
@@ -39,10 +39,12 @@ type attachedClient struct {
39 overlays *overlayRuntime39 overlays *overlayRuntime
40 overlayOnce sync.Once40 overlayOnce sync.Once
41 clientID [16]byte41 clientID [16]byte
42- // roleGeneration invalidates role-bound sends. A transition promoting a42+ // roleGeneration is the wire-facing capability generation. roleEffects is
43- // panel-sending client must serialize through sendMu/the activation barrier43+ // the linearization gate for every role-bound observable operation. A role
44- // before publishing its generation.44+ // transition freezes and drains this gate before changing either generation
45+ // or the session/coordinator registries.
45 roleGeneration atomic.Uint6446 roleGeneration atomic.Uint64
47+ roleEffects roleEffectGate
46 resumeCapable bool48 resumeCapable bool
47 resumeToken uint6449 resumeToken uint64
48 parked bool50 parked bool
@@ -236,14 +238,6 @@ func (ac *attachedClient) transportSnapshotCurrent(expected transportSnapshot) b
236 return ac.tr == expected.transport && ac.transportIncarnation == expected.incarnation238 return ac.tr == expected.transport && ac.transportIncarnation == expected.incarnation
237 }239 }
238 240
239-func (ac *attachedClient) closeTransport() error {
240- tr := ac.transport()
241- if tr == nil {
242- return nil
243- }
244- return tr.Close()
245-}
246-
247 func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error {241 func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error {
248 if tr == nil {242 if tr == nil {
249 return nil243 return nil
@@ -267,22 +261,6 @@ func (ac *attachedClient) ackOutputState(state uint64) {
267 ac.output.ack(state)261 ac.output.ack(state)
268 }262 }
269 263
270-// send serialises a frame onto the client's transport.
271-func (ac *attachedClient) send(f ports.Frame) error {
272- _, err := ac.sendTransport(f)
273- return err
274-}
275-
276-func (ac *attachedClient) sendTransport(f ports.Frame) (ports.Transport, error) {
277- ac.sendMu.Lock()
278- defer ac.sendMu.Unlock()
279- tr := ac.transport()
280- if tr == nil {
281- return nil, errors.New("client transport is nil")
282- }
283- return tr, tr.Send(f)
284-}
285-
286 var errTransportReplaced = errors.New("client transport was replaced")264 var errTransportReplaced = errors.New("client transport was replaced")
287 265
288 // sendExpectedTransport writes only when expected is still the attachment's266 // sendExpectedTransport writes only when expected is still the attachment's
@@ -299,6 +277,21 @@ func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f po
299 return expected.transport.Send(f)277 return expected.transport.Send(f)
300 }278 }
301 279
280+func (ac *attachedClient) sendExpectedTransportForRole(expected transportSnapshot, f ports.Frame, ticket *roleEffectTicket) error {
281+ ac.sendMu.Lock()
282+ defer ac.sendMu.Unlock()
283+ if ticket == nil || ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) ||
284+ expected.transport == nil || !ticket.beginTransportSend(expected) {
285+ return errAttachmentTransition
286+ }
287+ err := expected.transport.Send(f)
288+ if err != nil {
289+ ticket.reportTransportFailure(expected)
290+ }
291+ ticket.endTransportSend()
292+ return err
293+}
294+
302 // boundedSend sends f to ac with a deadline watchdog: if the send (including295 // boundedSend sends f to ac with a deadline watchdog: if the send (including
303 // waiting on sendMu behind a wedged paint) does not complete within296 // waiting on sendMu behind a wedged paint) does not complete within
304 // detachNotifyTimeout, the transport is force-closed, failing the in-flight297 // detachNotifyTimeout, the transport is force-closed, failing the in-flight
@@ -349,6 +342,45 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
349 })342 })
350 }343 }
351 344
345+// boundedSendOutputErrTransportForRole sends a queued side effect only through
346+// the exact active attachment capability captured when the work was queued.
347+// The fresh ticket keeps role publication behind an admitted send; only the
348+// external Send interval is marked interruptible for replacement liveness.
349+func (d *Daemon) boundedSendOutputErrTransportForRole(token attachmentRoleToken, ticket *roleEffectTicket, b []byte) (ports.Transport, error) {
350+ if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil {
351+ return token.transport.transport, errAttachmentTransition
352+ }
353+ frame := token.ac.output.sideEffect(b, token.ac.echoAck.Load())
354+ expected := token.transport
355+ send := func(owned bool) error {
356+ ac := token.ac
357+ ac.sendMu.Lock()
358+ defer ac.sendMu.Unlock()
359+ if ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) {
360+ return errAttachmentTransition
361+ }
362+ interruptible := ticket.beginTransportSend(expected)
363+ if !interruptible {
364+ return errAttachmentTransition
365+ }
366+ var err error
367+ if owned {
368+ err = expected.transport.(ports.OwnedSynchronousTransport).SendSynchronous(frame)
369+ } else {
370+ err = expected.transport.Send(frame)
371+ }
372+ if err != nil {
373+ ticket.reportTransportFailure(expected)
374+ }
375+ ticket.endTransportSend()
376+ return err
377+ }
378+ if _, owned := expected.transport.(ports.OwnedSynchronousTransport); owned {
379+ return expected.transport, send(true)
380+ }
381+ return d.boundedSendWith(expected.transport, func() error { return send(false) })
382+}
383+
352 var errSendTimedOut = errors.New("send timed out")384 var errSendTimedOut = errors.New("send timed out")
353 385
354 func (d *Daemon) boundedSendWith(tr ports.Transport, send func() error) (ports.Transport, error) {386 func (d *Daemon) boundedSendWith(tr ports.Transport, send func() error) (ports.Transport, error) {
@@ -376,12 +408,18 @@ func (d *Daemon) boundedSendWithTimeout(timeout time.Duration, tr ports.Transpor
376 }408 }
377 }409 }
378 410
379-// notifyDetachedAsync delivers a best-effort Detached notice off the caller's411+type detachedAttachmentSnapshot struct {
380-// goroutine and then closes the transport. Session teardown (killSession) must412+ ac *attachedClient
381-// complete regardless of client state, so the notice is both asynchronous and413+ transport transportSnapshot
382-// deadline-bounded; Serve waits for pending notices before force-closing414+}
383-// connections so a graceful notice is not raced by the hard close.415+
384-func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {416+// notifyDetachedSnapshotAsync sends and closes only the transport incarnation
417+// captured by atomic session teardown. It cannot touch a later link installed
418+// on the same attachedClient object.
419+func (d *Daemon) notifyDetachedSnapshotAsync(snapshot detachedAttachmentSnapshot, reason uint8) {
420+ if snapshot.ac == nil || snapshot.transport.transport == nil {
421+ return
422+ }
385 done := make(chan struct{})423 done := make(chan struct{})
386 d.mu.Lock()424 d.mu.Lock()
387 // Prune completed entries so the slice stays bounded by the number of425 // Prune completed entries so the slice stays bounded by the number of
@@ -399,8 +437,17 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
399 437
400 go func() {438 go func() {
401 defer close(done)439 defer close(done)
402- d.boundedSend(ac, frameDetached(reason))440+ _, err := d.boundedSendWith(snapshot.transport.transport, func() error {
403- _ = ac.closeTransport()441+ return snapshot.ac.sendExpectedTransport(snapshot.transport, frameDetached(reason))
442+ })
443+ if errors.Is(err, errSendTimedOut) {
444+ _ = snapshot.ac.closeCapturedTransport(snapshot.transport.transport)
445+ }
446+ revoked := snapshot.ac.revokeTransport(snapshot.transport.transport)
447+ if revoked == nil {
448+ revoked = snapshot.transport.transport
449+ }
450+ _ = snapshot.ac.closeCapturedTransport(revoked)
404 d.log.Info("client detached", "reason", reason)451 d.log.Info("client detached", "reason", reason)
405 }()452 }()
406 }453 }
@@ -428,13 +475,39 @@ type attachClientOptions struct {
428 maxOutputInFlight uint8475 maxOutputInFlight uint8
429 }476 }
430 477
431-func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {478+func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, error) {
432- ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, opts)479+ d.mu.Lock()
433- cleanup.finish()480+ ac := d.prepareAttachedClientLocked(tr, sz, opts)
434- return ac, old481+ d.mu.Unlock()
482+ result, err := d.transitionAttachment(attachmentTransitionRequest{
483+ target: sess,
484+ next: ac,
485+ expectedRole: attachmentDetached,
486+ targetRole: attachmentActive,
487+ expectedTransport: ac.transportSnapshot(),
488+ ready: false,
489+ })
490+ if err != nil {
491+ if tr != nil {
492+ _ = tr.Close()
493+ }
494+ return nil, nil, err
495+ }
496+ d.finishAttachedClient(sess, ac, opts)
497+ d.deferAttachmentTransitionCleanups(result)
498+ return ac, result.displaced.ac, nil
435 }499 }
436 500
437-func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, renderLifecycleCleanup) {501+func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
502+ d.touchMRU(sess)
503+ d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)
504+ d.applyHostTheme(sess, ac, themeui.Theme{}, true)
505+}
506+
507+// prepareAttachedClientLocked allocates one detached attachment. Caller holds
508+// d.mu only to allocate its resume token; role publication happens later after
509+// the caller releases every architecture lock.
510+func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, opts attachClientOptions) *attachedClient {
438 resumeToken := uint64(0)511 resumeToken := uint64(0)
439 if opts.resumeCapable {512 if opts.resumeCapable {
440 resumeToken = d.nextResumeTokenLocked()513 resumeToken = d.nextResumeTokenLocked()
@@ -448,54 +521,8 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
448 resumeToken: resumeToken,521 resumeToken: resumeToken,
449 }522 }
450 ac.initOverlays()523 ac.initOverlays()
451- ac.setSession(sess)
452 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)524 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
453- // Daemon attachment callers hold d.mu, serialising this preparation with525+ return ac
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- }
499 }526 }
500 527
501 // attachCoordinator is the sole direct attachment handoff. It creates at528 // attachCoordinator is the sole direct attachment handoff. It creates at
@@ -508,37 +535,7 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient,
508 }535 }
509 536
510 func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {537 func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
511- sess.mu.Lock()538+ rc := d.ensureRenderCoordinator(sess)
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()
542 var cleanup renderLifecycleCleanup539 var cleanup renderLifecycleCleanup
543 if old != nil {540 if old != nil {
544 cleanup = rc.beginReplace(old, current, ready)541 cleanup = rc.beginReplace(old, current, ready)
@@ -548,6 +545,45 @@ func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attached
548 return rc, cleanup545 return rc, cleanup
549 }546 }
550 547
548+// ensureRenderCoordinator publishes the session's single coordinator without
549+// binding an attachment lease. Centralized attachment transitions bind the
550+// lease later while their ordered session locks are still held.
551+func (d *Daemon) ensureRenderCoordinator(sess *session) *renderCoordinator {
552+ sess.mu.Lock()
553+ defer sess.mu.Unlock()
554+ if rc := sess.renderCoordinator(); rc != nil {
555+ return rc
556+ }
557+ var rc *renderCoordinator
558+ rc = newRenderCoordinator(renderCoordinatorOptions{
559+ clock: d.clock,
560+ observer: d.runtimeObserver,
561+ wake: func(w renderWake) {
562+ // Never reread sess.client here: a wake is bound to both its
563+ // attachment and its coordinator incarnation. The second check in
564+ // paint occurs under sendMu, closing a park/resume race after this
565+ // unlocked coordinator validation.
566+ if w.lease != nil && rc.wakeCurrent(w) {
567+ d.paint(sess, w.lease.attachment, w.reset, w.lease)
568+ }
569+ },
570+ ackReady: func() bool {
571+ sess.mu.Lock()
572+ attached := sess.client
573+ sess.mu.Unlock()
574+ if attached == nil {
575+ return false
576+ }
577+ attached.sendMu.Lock()
578+ ready := attached.output == nil || !attached.output.atCapacity()
579+ attached.sendMu.Unlock()
580+ return ready
581+ },
582+ })
583+ sess.installRenderCoordinator(rc)
584+ return rc
585+}
586+
551 // resetScreenDefaultColors clears the known default foreground/background587 // resetScreenDefaultColors clears the known default foreground/background
552 // colors on every tab in sess. Called once a client has been detached, this588 // colors on every tab in sess. Called once a client has been detached, this
553 // makes child OSC 10/11 queries go back to being swallowed (Known=false)589 // makes child OSC 10/11 queries go back to being swallowed (Known=false)
@@ -563,43 +599,48 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) {
563 d.applyHostTheme(sess, nil, themeui.Theme{}, true)599 d.applyHostTheme(sess, nil, themeui.Theme{}, true)
564 }600 }
565 601
566-// retireReplacedClient owns old-link retirement and obsolete render-worker602+// firstPaintForTransition is the only active post-transition paint entry
567-// joins outside the replacement handshake. If a render already owns sendMu,603+// point. It rejects a stale role capability before rebasing output and carries
568-// close immediately interrupts it; no Detached frame can safely overtake that604+// the exact coordinator lease through every resize and render effect.
569-// render. Otherwise the old link gets its bounded protocol notice first.605+func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {
570-func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecycleCleanup) {606+ if !token.activeCurrent() {
571- if old == nil {607+ return false
572- d.attachmentCleanupWg.Go(cleanup.finish)
573- return
574 }608 }
575- oldTransport := old.transportSnapshot().transport609+ ticket, admitted := token.ac.beginRoleEffect(token)
576- blockedRender := !old.sendMu.TryLock()610+ if !admitted {
577- if !blockedRender {611+ return false
578- old.sendMu.Unlock()
579 }612 }
580-613+ token.effect = ticket
581- d.attachmentCleanupWg.Go(func() {614+ defer token.endRoleEffect()
582- if !blockedRender {615+ if d.afterRoleEffectAdmitted != nil {
583- // A healthy idle client observes the required replacement notice. Its616+ d.afterRoleEffectAdmitted(token)
584- // watchdog still bounds a peer that stops draining after this check.617+ }
585- d.boundedSend(old, frameDetached(ports.ReasonReplaced))618+ if token.rebase {
619+ token.ac.sendMu.Lock()
620+ if token.ac.renderStages.handoffRebase != nil {
621+ token.ac.renderStages.handoffRebase()
586 }622 }
587- _ = old.closeCapturedTransport(old.revokeTransport(oldTransport))623+ if token.ac.output != nil {
588- d.unregisterPreview(old)624+ token.ac.output.rebase()
589- old.clearPreviousSession()625+ }
590- old.setSession(nil)626+ // Captures belong to the old session even when pane-local IDs happen
591- d.log.Info("client displaced")627+ // to be reused by the destination.
592- })628+ token.ac.captureFrames = nil
593- // Join independently: the close above releases a blocked render while the629+ token.ac.sendMu.Unlock()
594- // new attachment is already fully published.630+ }
595- d.attachmentCleanupWg.Go(cleanup.finish)631+ d.firstPaintWithLease(token.sess, token.ac, token.ac.size, token.lease)
632+ return true
596 }633 }
597 634
598 // firstPaint guarantees the freshly attached client sees the full screen: if635 // firstPaint guarantees the freshly attached client sees the full screen: if
599 // the tab size differs from the client's it resizes first, then immediately636 // 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 fallback637+// emits a full redraw. It is retained for direct test/headless setup; active
601-// timer.638+// role transitions use firstPaintForTransition with their exact lease.
602 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {639 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
640+ d.firstPaintWithLease(sess, ac, clientSize, nil)
641+}
642+
643+func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSize domain.Size, lease *attachmentLease) {
603 // Global notices raised while nothing was attached surface on this client.644 // Global notices raised while nothing was attached surface on this client.
604 // Drained before the early return below so a session without an active tab645 // Drained before the early return below so a session without an active tab
605 // cannot swallow the queue.646 // cannot swallow the queue.
@@ -614,20 +655,30 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
614 655
615 outerResizeAccepted := false656 outerResizeAccepted := false
616 if clientSize.Valid() && wsz != tabSize(clientSize) {657 if clientSize.Valid() && wsz != tabSize(clientSize) {
617- outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)658+ if lease == nil {
659+ outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)
660+ } else {
661+ outerResizeAccepted = d.requestTransactionalResizeForLease(sess, ac, lease, clientSize, true)
662+ }
618 }663 }
619 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)664 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
620 // Activation can synchronously resize a retained floating pane. An accepted665 // Activation can synchronously resize a retained floating pane. An accepted
621 // synchronous outer request already includes that pane and emits the reset,666 // synchronous outer request already includes that pane and emits the reset,
622 // but activation still performs its warmup work.667 // but activation still performs its warmup work.
623- activationResized := d.activateTabAfterResize(sess, tb, outerResizeAccepted)668+ activationResized := d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, ac, lease)
624 if !outerResizeAccepted && !activationResized {669 if !outerResizeAccepted && !activationResized {
625- d.invalidateRenderNow(sess, ac, true, "client.go")670+ if lease == nil {
671+ d.invalidateRenderNow(sess, ac, true, "client.go")
672+ } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) {
673+ rc.fireCurrent(false)
674+ }
626 }675 }
627 }676 }
628 677
629 // runConnLoop is the per-connection input router: it pumps client messages678 // runConnLoop is the per-connection input router: it pumps client messages
630-// until detach, EOF, or a transport error.679+// until detach, EOF, or a transport error. Role is resolved after every Recv so
680+// a transport displaced while blocked in Recv immediately adopts its restricted
681+// snatched routing rather than executing one stale active frame.
631 func (d *Daemon) runConnLoop(ac *attachedClient) {682 func (d *Daemon) runConnLoop(ac *attachedClient) {
632 tr := ac.transport()683 tr := ac.transport()
633 if tr == nil {684 if tr == nil {
@@ -643,7 +694,12 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
643 }694 }
644 f, err := tr.Recv()695 f, err := tr.Recv()
645 if err != nil {696 if err != nil {
646- d.clientGone(sess, ac, tr, false)697+ token := sess.attachmentToken(ac, tr)
698+ if token.role == attachmentSnatched {
699+ d.dropSnatchedAttachment(token)
700+ } else {
701+ d.clientGone(sess, ac, tr, false)
702+ }
647 return703 return
648 }704 }
649 if !ac.currentTransportIs(tr) {705 if !ac.currentTransportIs(tr) {
@@ -653,46 +709,177 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
653 if sess == nil {709 if sess == nil {
654 return710 return
655 }711 }
656- switch f.Type {712+ token := sess.attachmentToken(ac, tr)
657- case ports.MsgInput:713+ switch token.role {
658- if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {714+ case attachmentActive:
659- d.handleSequencedInput(sess, ac, in.InputSeq, in.Data)715+ if d.handleActiveClientFrame(token, f) {
716+ return
660 }717 }
661- case ports.MsgResize:718+ case attachmentSnatched:
662- if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {719+ if d.handleSnatchedClientFrame(token, f) {
663- d.resize(sess, ac, rz.Size)720+ return
664 }721 }
665- case ports.MsgTheme:722+ default:
666- if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {723+ return
667- d.applyTheme(sess, ac, th)724+ }
725+ }
726+}
727+
728+// handleActiveClientFrame owns the ordinary attached-client protocol. Keeping
729+// it separate makes the restricted snatched protocol an explicit allow-list.
730+func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool {
731+ if !token.activeEffect() {
732+ return false
733+ }
734+ if d.afterActiveFrameDispatch != nil {
735+ d.afterActiveFrameDispatch(token)
736+ }
737+ ticket, admitted := token.ac.beginRoleEffect(token)
738+ if !admitted {
739+ return false
740+ }
741+ token.effect = ticket
742+ defer token.endRoleEffect()
743+ if d.afterRoleEffectAdmitted != nil {
744+ d.afterRoleEffectAdmitted(token)
745+ }
746+ switch f.Type {
747+ case ports.MsgInput:
748+ if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
749+ d.handleSequencedInputForRole(token, in.InputSeq, in.Data)
750+ }
751+ case ports.MsgResize:
752+ if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() {
753+ d.requestTransactionalResizeForLease(token.sess, token.ac, token.lease, rz.Size, false)
754+ }
755+ case ports.MsgTheme:
756+ if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
757+ d.applyThemeForRole(token, th)
758+ }
759+ case ports.MsgImagePush:
760+ if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
761+ d.handleSequencedImagePushForRole(token, ip.InputSeq, ip)
762+ }
763+ case ports.MsgClientNotice:
764+ if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {
765+ d.handleClientNoticeForRole(token, notice)
766+ } else {
767+ d.log.Warn("malformed client notice", "err", derr)
768+ }
769+ case ports.MsgDetach:
770+ if token.activeEffect() {
771+ d.clientGoneForRole(token, true)
772+ return true
773+ }
774+ case ports.MsgAck:
775+ if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
776+ d.ackActiveOutput(token, ack.AckedStateNum)
777+ }
778+ case ports.MsgPing:
779+ if err := token.sendActiveControl(framePong()); err != nil {
780+ token.endRoleEffect()
781+ d.detachOnSendError(token.sess, token.ac, token.transport.transport)
782+ return true
783+ }
784+ default:
785+ // Unknown/out-of-band client messages are ignored so a newer
786+ // client can add message types without breaking an older daemon.
787+ }
788+ return false
789+}
790+
791+func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {
792+ ac := token.ac
793+ if token.effect == nil || token.effect.ended.Load() {
794+ return false
795+ }
796+ ac.sendMu.Lock()
797+ ac.output.ack(state)
798+ ac.sendMu.Unlock()
799+ if rc := token.sess.renderCoordinator(); rc != nil {
800+ rc.notifyAckForLease(token.lease)
801+ }
802+ return true
803+}
804+
805+// handleSnatchedClientFrame is intentionally an allow-list. Strict resume and
806+// quit actions are introduced separately; until then ordinary input is ignored.
807+func (d *Daemon) handleSnatchedClientFrame(token attachmentRoleToken, f ports.Frame) bool {
808+ if !token.current() {
809+ return false
810+ }
811+ ticket, admitted := token.ac.beginRoleEffect(token)
812+ if !admitted {
813+ return false
814+ }
815+ token.effect = ticket
816+ defer token.endRoleEffect()
817+ if d.afterRoleEffectAdmitted != nil {
818+ d.afterRoleEffectAdmitted(token)
819+ }
820+ switch f.Type {
821+ case ports.MsgInput:
822+ if in, err := ports.UnmarshalInput(f.Payload); err == nil {
823+ d.handleSnatchedInput(token, in)
824+ }
825+ case ports.MsgResize:
826+ if resize, err := ports.UnmarshalResize(f.Payload); err == nil && resize.Size.Valid() {
827+ ac := token.ac
828+ ac.sendMu.Lock()
829+ if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
830+ ac.sendMu.Unlock()
831+ return false
668 }832 }
669- case ports.MsgImagePush:833+ ac.size = resize.Size
670- if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {834+ ac.sendMu.Unlock()
671- d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip)835+ if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil {
836+ token.endRoleEffect()
837+ d.dropSnatchedAttachment(token)
838+ return true
672 }839 }
673- case ports.MsgClientNotice:840+ }
674- if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {841+ case ports.MsgTheme:
675- d.handleClientNotice(sess, ac, notice)842+ if theme, err := ports.UnmarshalTheme(f.Payload); err == nil {
676- } else {843+ ac := token.ac
677- d.log.Warn("malformed client notice", "err", derr)844+ ac.sendMu.Lock()
845+ if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
846+ ac.sendMu.Unlock()
847+ return false
678 }848 }
679- case ports.MsgDetach:849+ clientTheme := themeFromMessage(theme)
680- d.clientGone(sess, ac, tr, true)850+ ac.setClientTheme(clientTheme)
681- return851+ ac.setAppliedTheme(d.resolveAppliedTheme(clientTheme))
682- case ports.MsgAck:852+ ac.sendMu.Unlock()
683- if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {853+ if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil {
684- ac.ackOutputState(ack.AckedStateNum)854+ token.endRoleEffect()
685- if rc := sess.renderCoordinator(); rc != nil {855+ d.dropSnatchedAttachment(token)
686- rc.notifyAck()856+ return true
687- }
688 }857 }
689- case ports.MsgPing:
690- _ = ac.send(framePong())
691- default:
692- // Unknown/out-of-band client messages are ignored so a newer
693- // client can add message types without breaking an older daemon.
694 }858 }
859+ case ports.MsgAck:
860+ if ack, err := ports.UnmarshalAck(f.Payload); err == nil {
861+ token.ac.ackOutputState(ack.AckedStateNum)
862+ }
863+ case ports.MsgPing:
864+ if err := d.sendSnatchedControl(token, framePong()); err != nil {
865+ token.endRoleEffect()
866+ d.dropSnatchedAttachment(token)
867+ return true
868+ }
869+ case ports.MsgDetach:
870+ _ = d.sendSnatchedControl(token, frameDetached(ports.ReasonDetach))
871+ token.endRoleEffect()
872+ d.dropSnatchedAttachment(token)
873+ return true
695 }874 }
875+ return false
876+}
877+
878+// handleSnatchedInput is the narrow seam for the future strict snatched-action
879+// parser. Ordinary terminal input is deliberately ignored and never enters the
880+// key router or PTY path.
881+func (d *Daemon) handleSnatchedInput(token attachmentRoleToken, _ ports.Input) {
882+ _ = token
696 }883 }
697 884
698 // clientGone detaches ac if it is still the session's current client. The885 // clientGone detaches ac if it is still the session's current client. The
@@ -704,6 +891,23 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
704 if !d.detachIfCurrent(sess, ac) {891 if !d.detachIfCurrent(sess, ac) {
705 return // already displaced by a newer client; nothing to do892 return // already displaced by a newer client; nothing to do
706 }893 }
894+ d.finishClientGone(sess, ac, failed, explicit)
895+}
896+
897+func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool {
898+ if token.effect == nil {
899+ return false
900+ }
901+ token.effect.bindActionEnd(d, "detach")
902+ token.effect.End()
903+ if !d.detachIfRoleCurrent(token) {
904+ return false
905+ }
906+ d.finishClientGone(token.sess, token.ac, token.transport.transport, explicit)
907+ return true
908+}
909+
910+func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
707 if rc := sess.renderCoordinator(); rc != nil {911 if rc := sess.renderCoordinator(); rc != nil {
708 rc.noteDetach(ac)912 rc.noteDetach(ac)
709 }913 }
@@ -740,8 +944,8 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
740 944
741 // detachIfCurrent clears the client iff ac is the current one, reporting945 // detachIfCurrent clears the client iff ac is the current one, reporting
742 946
743-func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {947+func themeFromMessage(msg ports.Theme) themeui.Theme {
744- clientTheme := themeui.Theme{948+ return themeui.Theme{
745 Foreground: msg.Foreground,949 Foreground: msg.Foreground,
746 Background: msg.Background,950 Background: msg.Background,
747 Palette: msg.Palette,951 Palette: msg.Palette,
@@ -753,6 +957,10 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
753 SchemeKnown: msg.SchemeKnown,957 SchemeKnown: msg.SchemeKnown,
754 Light: msg.Light,958 Light: msg.Light,
755 }959 }
960+}
961+
962+func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
963+ clientTheme := themeFromMessage(msg)
756 ac.setClientTheme(clientTheme)964 ac.setClientTheme(clientTheme)
757 965
758 if !d.applyHostTheme(sess, ac, clientTheme, false) {966 if !d.applyHostTheme(sess, ac, clientTheme, false) {
@@ -761,6 +969,24 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
761 d.invalidateRender(sess, ac, true, "client.go")969 d.invalidateRender(sess, ac, true, "client.go")
762 }970 }
763 971
972+func (d *Daemon) applyThemeForRole(token attachmentRoleToken, msg ports.Theme) bool {
973+ if !token.activeEffect() {
974+ return false
975+ }
976+ clientTheme := themeFromMessage(msg)
977+ token.ac.setClientTheme(clientTheme)
978+ if !token.activeEffect() || !d.applyHostTheme(token.sess, token.ac, clientTheme, false) {
979+ return false
980+ }
981+ if !token.activeEffect() {
982+ return false
983+ }
984+ if rc := token.sess.renderCoordinator(); rc != nil {
985+ return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
986+ }
987+ return false
988+}
989+
764 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {990 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
765 d.requestTransactionalResize(sess, ac, sz, false)991 d.requestTransactionalResize(sess, ac, sz, false)
766 }992 }
@@ -771,32 +997,78 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
771 // publishing sess.client. Never retain sess.mu while touching notice or overlay997 // publishing sess.client. Never retain sess.mu while touching notice or overlay
772 // state, and retain the routingMu -> sess.mu order used by attachment paths.998 // state, and retain the routingMu -> sess.mu order used by attachment paths.
773 func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) {999 func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) {
1000+ if sess == nil || ac == nil {
1001+ return
1002+ }
1003+ tr := ac.transport()
1004+ token := sess.attachmentToken(ac, tr)
1005+ if token.lease == nil {
1006+ // Direct test/headless callers retain pointer-based routing. Production
1007+ // client frames always carry the exact lease through the role path below.
1008+ d.handleClientNoticeDirect(sess, ac, notice)
1009+ return
1010+ }
1011+ d.handleClientNoticeForRole(token, notice)
1012+}
1013+
1014+func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice ports.ClientNotice) {
774 d.notices.routingMu.Lock()1015 d.notices.routingMu.Lock()
775- defer d.notices.routingMu.Unlock()1016+ if !token.activeEffect() {
1017+ d.notices.routingMu.Unlock()
1018+ return
1019+ }
1020+ if d.notices.beforeClientNoticeMutation != nil {
1021+ d.notices.beforeClientNoticeMutation()
1022+ }
1023+ if !token.activeEffect() {
1024+ d.notices.routingMu.Unlock()
1025+ return
1026+ }
1027+ repaint := d.mutateClientNotice(token.sess, token.ac, notice)
1028+ d.notices.routingMu.Unlock()
1029+ if repaint && token.activeEffect() {
1030+ d.repaintForNotice(token.ac)
1031+ }
1032+}
776 1033
1034+func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice ports.ClientNotice) {
1035+ d.notices.routingMu.Lock()
777 sess.mu.Lock()1036 sess.mu.Lock()
778 current := sess.client == ac1037 current := sess.client == ac
779 sess.mu.Unlock()1038 sess.mu.Unlock()
780 if !current {1039 if !current {
1040+ d.notices.routingMu.Unlock()
781 return1041 return
782 }1042 }
783 if d.notices.beforeClientNoticeMutation != nil {1043 if d.notices.beforeClientNoticeMutation != nil {
784 d.notices.beforeClientNoticeMutation()1044 d.notices.beforeClientNoticeMutation()
785 }1045 }
1046+ repaint := d.mutateClientNotice(sess, ac, notice)
1047+ d.notices.routingMu.Unlock()
1048+ if repaint {
1049+ d.repaintForNotice(ac)
1050+ }
1051+}
786 1052
1053+func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) bool {
787 switch notice.Action {1054 switch notice.Action {
788 case ports.ClientNoticeClipboardFallback:1055 case ports.ClientNoticeClipboardFallback:
789- d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V")1056+ return d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V")
790 case ports.ClientNoticeClipboardTooLarge:1057 case ports.ClientNoticeClipboardTooLarge:
791- d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste")1058+ return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste")
792 case ports.ClientNoticeLinkDegraded:1059 case ports.ClientNoticeLinkDegraded:
793- d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded")1060+ return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded")
794 case ports.ClientNoticeLinkConnected:1061 case ports.ClientNoticeLinkConnected:
795- d.dismissToast(ac, domain.NoticeConnection, sess.id)1062+ return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, sess.id)
1063+ default:
1064+ return false
796 }1065 }
797 }1066 }
798 1067
799-func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) {1068+// recordClientNotice mutates notice history and the selected attachment while
1069+// routingMu keeps replacement from changing that selection. Rendering is left
1070+// to the caller after it releases routingMu.
1071+func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool {
800 n := d.notices.record(domain.Notification{1072 n := d.notices.record(domain.Notification{
801 Code: code,1073 Code: code,
802 Severity: sev,1074 Severity: sev,
@@ -804,7 +1076,7 @@ func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domai
804 Time: d.clock.Now(),1076 Time: d.clock.Now(),
805 SessionID: sess.id,1077 SessionID: sess.id,
806 })1078 })
807- d.showToast(ac, n)1079+ return d.publishToast(ac, n)
808 }1080 }
809 1081
810 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The1082 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
@@ -820,20 +1092,58 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
820 return1092 return
821 }1093 }
822 if d.detachIfCurrent(sess, ac) {1094 if d.detachIfCurrent(sess, ac) {
823- if rc := sess.renderCoordinator(); rc != nil {1095+ d.finishSendErrorDetach(sess, ac, failed)
824- rc.noteDetach(ac)1096+ }
825- }1097+}
826- d.unregisterPreview(ac)1098+
827- if d.parkAttachment(sess, ac) {1099+func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {
828- _ = ac.closeCapturedTransport(failed)1100+ d.detachOnRoleSendErrorUntil(token, failed, nil)
829- d.log.Warn("parked client after send error", "session", sess.name)1101+}
830- return1102+
831- }1103+func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) {
832- d.resetScreenDefaultColors(sess)1104+ if failed != token.transport.transport {
833- ac.clearPreviousSession()1105+ return
1106+ }
1107+ if d.detachIfRoleCurrentUntil(token, done) {
1108+ d.finishSendErrorDetach(token.sess, token.ac, failed)
1109+ }
1110+}
1111+
1112+// reserveRoleSendErrorCleanup accounts for cleanup before End releases the
1113+// role gate. This closes the WaitGroup Add/Wait race with terminal teardown;
1114+// the returned launch function must be invoked immediately after ticket End.
1115+func (d *Daemon) reserveRoleSendErrorCleanup(token attachmentRoleToken, failed ports.Transport) func() {
1116+ d.attachmentCleanupWg.Add(1)
1117+ return func() {
1118+ go func() {
1119+ defer d.attachmentCleanupWg.Done()
1120+ if d.afterRoleSendErrorCleanup != nil {
1121+ defer d.afterRoleSendErrorCleanup()
1122+ }
1123+ if d.beforeRoleSendErrorCleanup != nil {
1124+ d.beforeRoleSendErrorCleanup(token)
1125+ }
1126+ deadline := newRoleEffectDrainDeadline(d.clock)
1127+ defer deadline.stop()
1128+ d.detachOnRoleSendErrorUntil(token, failed, deadline.Done)
1129+ }()
1130+ }
1131+}
1132+
1133+func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.Transport) {
1134+ if rc := sess.renderCoordinator(); rc != nil {
1135+ rc.noteDetach(ac)
1136+ }
1137+ d.unregisterPreview(ac)
1138+ if d.parkAttachment(sess, ac) {
834 _ = ac.closeCapturedTransport(failed)1139 _ = ac.closeCapturedTransport(failed)
835- d.log.Warn("detached client after send error", "session", sess.name)1140+ d.log.Warn("parked client after send error", "session", sess.name)
1141+ return
836 }1142 }
1143+ d.resetScreenDefaultColors(sess)
1144+ ac.clearPreviousSession()
1145+ _ = ac.closeCapturedTransport(failed)
1146+ d.log.Warn("detached client after send error", "session", sess.name)
837 }1147 }
838 1148
839 // killSession removes a session and tears down its resources. It is1149 // killSession removes a session and tears down its resources. It is
M internal/usecase/daemon/clipboard.go +96 -14
@@ -4,6 +4,7 @@
4 package daemon4 package daemon
5 5
6 import (6 import (
7+ "errors"
7 "os"8 "os"
8 9
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
@@ -25,12 +26,27 @@ var (
25 clipPasteCloseMarker = []byte("\x1b[201~")26 clipPasteCloseMarker = []byte("\x1b[201~")
26 )27 )
27 28
28-// handleSequencedImagePush routes an ImagePush through the same input-sequence29+func (d *Daemon) handleSequencedImagePushForRole(token attachmentRoleToken, _ uint64, ip ports.ImagePush) {
29-// surface as ordinary keystrokes. Echo prediction is still conservative; the30+ if !token.activeEffect() {
30-// sequence is carried so resume/dedup plumbing can order image pushes with31+ return
31-// surrounding input when that state reader grows beyond ordinary input.32+ }
32-func (d *Daemon) handleSequencedImagePush(sess *session, _ *attachedClient, _ uint64, ip ports.ImagePush) {33+ d.handleImagePushForRole(token, ip)
33- d.handleImagePush(sess, ip)34+}
35+
36+func (d *Daemon) handleImagePushForRole(token attachmentRoleToken, ip ports.ImagePush) {
37+ if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.activeEffect() {
38+ return
39+ }
40+ path, err := d.writeClipboardImageForRole(token, ip)
41+ if err != nil {
42+ d.log.Error("writing clipboard image failed", "err", err)
43+ return
44+ }
45+ if !token.activeEffect() {
46+ _ = os.Remove(path)
47+ return
48+ }
49+ d.injectClipboardPathForRole(token, path)
34 }50 }
35 51
36 // handleImagePush writes an ImagePush's bytes to a temp file and injects the52 // handleImagePush writes an ImagePush's bytes to a temp file and injects the
@@ -58,6 +74,33 @@ func (d *Daemon) handleImagePush(sess *session, ip ports.ImagePush) {
58 // writeClipboardImage writes ip's bytes to a new 0600 temp file and records74 // writeClipboardImage writes ip's bytes to a new 0600 temp file and records
59 // the path on sess for best-effort cleanup at session end (killSession).75 // the path on sess for best-effort cleanup at session end (killSession).
60 func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) {76 func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) {
77+ path, err := d.createClipboardImage(ip)
78+ if err != nil {
79+ return "", err
80+ }
81+ sess.mu.Lock()
82+ sess.clipFiles = append(sess.clipFiles, path)
83+ sess.mu.Unlock()
84+ return path, nil
85+}
86+
87+func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports.ImagePush) (string, error) {
88+ path, err := d.createClipboardImage(ip)
89+ if err != nil {
90+ return "", err
91+ }
92+ token.sess.mu.Lock()
93+ if !token.activeEffectSessionLocked() {
94+ token.sess.mu.Unlock()
95+ _ = os.Remove(path)
96+ return "", errAttachmentTransition
97+ }
98+ token.sess.clipFiles = append(token.sess.clipFiles, path)
99+ token.sess.mu.Unlock()
100+ return path, nil
101+}
102+
103+func (d *Daemon) createClipboardImage(ip ports.ImagePush) (string, error) {
61 dir := d.tempDir104 dir := d.tempDir
62 if dir == "" {105 if dir == "" {
63 dir = os.TempDir()106 dir = os.TempDir()
@@ -76,9 +119,6 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
76 _ = os.Remove(path)119 _ = os.Remove(path)
77 return "", err120 return "", err
78 }121 }
79- sess.mu.Lock()
80- sess.clipFiles = append(sess.clipFiles, path)
81- sess.mu.Unlock()
82 return path, nil122 return path, nil
83 }123 }
84 124
@@ -86,6 +126,17 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
86 // the same PTY write path as ordinary input, wrapped in bracketed-paste126 // the same PTY write path as ordinary input, wrapped in bracketed-paste
87 // markers iff the pane's Screen reports bracketed-paste mode enabled.127 // markers iff the pane's Screen reports bracketed-paste mode enabled.
88 func (d *Daemon) injectClipboardPath(sess *session, path string) {128 func (d *Daemon) injectClipboardPath(sess *session, path string) {
129+ d.injectClipboardPathToTarget(sess, path, nil)
130+}
131+
132+func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path string) {
133+ if !token.activeEffect() {
134+ return
135+ }
136+ d.injectClipboardPathToTarget(token.sess, path, &token)
137+}
138+
139+func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) {
89 tb := sess.activeTab()140 tb := sess.activeTab()
90 if tb == nil {141 if tb == nil {
91 return142 return
@@ -109,6 +160,9 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
109 wrapped = append(wrapped, clipPasteCloseMarker...)160 wrapped = append(wrapped, clipPasteCloseMarker...)
110 data = wrapped161 data = wrapped
111 }162 }
163+ if token != nil && !token.activeEffect() {
164+ return
165+ }
112 d.writeToPane(sess, p, data)166 d.writeToPane(sess, p, data)
113 }167 }
114 168
@@ -132,8 +186,8 @@ func clipboardExt(mime string) string {
132 }186 }
133 187
134 type clipboardForward struct {188 type clipboardForward struct {
135- ac *attachedClient189+ token attachmentRoleToken
136- seq []byte190+ seq []byte
137 }191 }
138 192
139 // forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set193 // forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set
@@ -161,11 +215,31 @@ func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) {
161 }215 }
162 }216 }
163 ac := sess.client217 ac := sess.client
218+ sess.mu.Unlock()
164 if ac == nil {219 if ac == nil {
220+ return
221+ }
222+ transport := ac.transport()
223+ token := sess.attachmentToken(ac, transport)
224+ if !token.activeCurrent() {
225+ return
226+ }
227+
228+ sess.mu.Lock()
229+ if sess.ctx != nil {
230+ select {
231+ case <-sess.ctx.Done():
232+ sess.mu.Unlock()
233+ return
234+ default:
235+ }
236+ }
237+ if sess.client != ac || ac.roleGeneration.Load() != token.generation ||
238+ !ac.transportSnapshotCurrent(token.transport) || ac.currentSession() != sess {
165 sess.mu.Unlock()239 sess.mu.Unlock()
166 return240 return
167 }241 }
168- sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{ac: ac, seq: seq})242+ sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{token: token, seq: seq})
169 if !sess.clipboardWorkerRunning {243 if !sess.clipboardWorkerRunning {
170 sess.clipboardWorkerRunning = true244 sess.clipboardWorkerRunning = true
171 go d.clipboardWorker(sess)245 go d.clipboardWorker(sess)
@@ -179,9 +253,17 @@ func (d *Daemon) clipboardWorker(sess *session) {
179 if !ok {253 if !ok {
180 return254 return
181 }255 }
182- failed, err := d.boundedSendOutputErrTransport(item.ac, item.seq)256+ ticket, admitted := item.token.ac.beginRoleEffect(item.token)
257+ if !admitted {
258+ continue
259+ }
260+ failed, err := d.boundedSendOutputErrTransportForRole(item.token, ticket, item.seq)
261+ if errors.Is(err, errSendTimedOut) {
262+ _ = item.token.ac.closeCapturedTransport(failed)
263+ }
264+ ticket.End()
183 if err != nil {265 if err != nil {
184- d.detachOnSendError(sess, item.ac, failed)266+ d.detachOnRoleSendError(item.token, failed)
185 }267 }
186 }268 }
187 }269 }
M internal/usecase/daemon/clipboard_test.go +74 -4
@@ -3,10 +3,12 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "encoding/base64"5 "encoding/base64"
6+ "errors"
6 "io"7 "io"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
9 "strings"10 "strings"
11+ "sync"
10 "testing"12 "testing"
11 "time"13 "time"
12 14
@@ -200,6 +202,13 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
200 return p202 return p
201 }203 }
202 204
205+func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) {
206+ rc := d.attachCoordinator(sess, nil, ac, true)
207+ token := sess.attachmentToken(ac, tr)
208+ token.lease = rc.attachmentLease(ac)
209+ ac.publishRoleCapability(token)
210+}
211+
203 func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {212 func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
204 d := newTestDaemon(t, nil, stubClock{})213 d := newTestDaemon(t, nil, stubClock{})
205 p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))214 p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
@@ -213,14 +222,22 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
213 sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}222 sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
214 ac.setSession(sess)223 ac.setSession(sess)
215 d.sessions[sess.id] = sess224 d.sessions[sess.id] = sess
225+ publishActiveClipboardCapability(d, sess, ac, tr)
216 226
217 d.sessWg.Add(1)227 d.sessWg.Add(1)
218 d.ptyReader(sess, win, win.focusedPane())228 d.ptyReader(sess, win, win.focusedPane())
219 229
220- f := awaitFrame(t, sends, ports.MsgOutput)230+ var clipboardOutput string
221- out, err := ports.UnmarshalOutput(f.Payload)231+ for range 3 {
222- require.NoError(t, err)232+ f := awaitFrame(t, sends, ports.MsgOutput)
223- require.Contains(t, string(out.Data), "\x1b]52;c;aGVsbG8=\x07")233+ out, err := ports.UnmarshalOutput(f.Payload)
234+ require.NoError(t, err)
235+ clipboardOutput = string(out.Data)
236+ if strings.Contains(clipboardOutput, "\x1b]52;c;aGVsbG8=\x07") {
237+ break
238+ }
239+ }
240+ require.Contains(t, clipboardOutput, "\x1b]52;c;aGVsbG8=\x07")
224 }241 }
225 242
226 func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {243 func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
@@ -289,6 +306,57 @@ func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
289 })306 })
290 }307 }
291 308
309+type staleClipboardErrorTransport struct {
310+ mu sync.Mutex
311+ sends int
312+}
313+
314+func (t *staleClipboardErrorTransport) Send(ports.Frame) error {
315+ t.mu.Lock()
316+ t.sends++
317+ t.mu.Unlock()
318+ return errors.New("stale clipboard send")
319+}
320+
321+func (*staleClipboardErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
322+func (*staleClipboardErrorTransport) Close() error { return nil }
323+
324+func (t *staleClipboardErrorTransport) sendCount() int {
325+ t.mu.Lock()
326+ defer t.mu.Unlock()
327+ return t.sends
328+}
329+
330+func TestQueuedClipboardBeforeSnatchDropsExactStaleCapability(t *testing.T) {
331+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
332+ oldTransport := &staleClipboardErrorTransport{}
333+ old.replaceTransport(oldTransport)
334+ rc := d.attachCoordinator(sess, nil, old, true)
335+ token := sess.attachmentToken(old, oldTransport)
336+ token.lease = rc.attachmentLease(old)
337+ old.publishRoleCapability(token)
338+
339+ sess.mu.Lock()
340+ sess.clipboardWorkerRunning = true
341+ sess.mu.Unlock()
342+ d.forwardClipboardAsync(sess, base64.StdEncoding.EncodeToString([]byte("queued")))
343+
344+ newTransport := &closeTrackingTransport{}
345+ next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}
346+ next.initOverlays()
347+ _, err := d.transitionAttachment(attachmentTransitionRequest{
348+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
349+ expectedTransport: next.transportSnapshot(), ready: true,
350+ })
351+ require.NoError(t, err)
352+
353+ d.clipboardWorker(sess)
354+ require.Zero(t, oldTransport.sendCount(), "stale queued work reached its captured failing transport")
355+ require.Empty(t, newTransport.Sends(), "stale queued work was redirected to the replacement")
356+ require.Same(t, next, sess.client, "stale clipboard send handling detached the current client")
357+ require.False(t, newTransport.Closed())
358+}
359+
292 func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {360 func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
293 d := newTestDaemon(t, nil, stubClock{})361 d := newTestDaemon(t, nil, stubClock{})
294 tr := newBlockingClipboardTransport()362 tr := newBlockingClipboardTransport()
@@ -298,6 +366,8 @@ func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
298 defer cancel()366 defer cancel()
299 sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac}367 sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac}
300 ac.setSession(sess)368 ac.setSession(sess)
369+ d.sessions[sess.id] = sess
370+ publishActiveClipboardCapability(d, sess, ac, tr)
301 371
302 first := base64.StdEncoding.EncodeToString([]byte("first"))372 first := base64.StdEncoding.EncodeToString([]byte("first"))
303 second := base64.StdEncoding.EncodeToString([]byte("second"))373 second := base64.StdEncoding.EncodeToString([]byte("second"))
M internal/usecase/daemon/daemon.go +93 -27
@@ -112,18 +112,53 @@ type Daemon struct {
112 // and before coordinator epoch admission. It is a deterministic regression112 // and before coordinator epoch admission. It is a deterministic regression
113 // seam for stale resize publication.113 // seam for stale resize publication.
114 beforeSessionResizePublication func()114 beforeSessionResizePublication func()
115- ptys ports.PTYFactory115+ // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs
116- clock ports.Clock116+ // after one overlay-family mutex is released and before the next is taken.
117- log *slog.Logger117+ afterSnatchOverlayFamily func(string)
118- runtimeObserver ports.RuntimeObserver118+ // afterActiveFrameDispatch is a deterministic test seam after the connection
119- baseEnv []string119+ // loop's first role check and before a decoded active frame takes effect.
120- shell string120+ afterActiveFrameDispatch func(attachmentRoleToken)
121- shellArgs []string121+ // afterRoleEffectParticipantsSnapshotted observes immutable role-gate
122- shellOverride bool122+ // participants after architecture preflight locks are released and before
123- persistEnabled bool123+ // the globally ordered freeze begins.
124- catalogue ports.Catalogue124+ afterRoleEffectParticipantsSnapshotted func(string, []*attachedClient)
125- catalogueRecords []domain.CatalogueRecord125+ // afterRoleEffectGateFrozen observes each participant after it is frozen in
126- catalogueRecordsProvided bool126+ // immutable identity order and before drain/publication continues.
127+ afterRoleEffectGateFrozen func(string, *attachedClient)
128+ // afterRoleEffectsFrozen observes the lock-free boundary after all affected
129+ // attachment gates are frozen and drained, before architecture publication.
130+ afterRoleEffectsFrozen func()
131+ // afterDetachRoleEffectsFrozen observes terminal detach after it wins the
132+ // attachment gate but before it checks session ownership.
133+ afterDetachRoleEffectsFrozen func()
134+ // afterDisplacedCleanupStarted observes deferred displaced cleanup before it
135+ // synchronizes with the attachment gate.
136+ afterDisplacedCleanupStarted func()
137+ // afterRoleEffectAdmitted is a deterministic test seam after a frame/paint
138+ // reserves its exact capability and before its first observable mutation.
139+ afterRoleEffectAdmitted func(attachmentRoleToken)
140+ // afterDelayedKeyEffectAttempt observes whether a timer callback acquired a
141+ // fresh exact capability before producing PTY, action, or overlay effects.
142+ afterDelayedKeyEffectAttempt func(bool)
143+ // afterActionRoleEffectEnded observes action-specific admission release. It
144+ // is a deterministic seam for proving no role-bound mutation follows release.
145+ afterActionRoleEffectEnded func(string)
146+ // beforeRoleSendErrorCleanup pauses asynchronous render-failure retirement
147+ // after the render ticket ends and before exact lifecycle validation.
148+ beforeRoleSendErrorCleanup func(attachmentRoleToken)
149+ afterRoleSendErrorCleanup func()
150+ ptys ports.PTYFactory
151+ clock ports.Clock
152+ log *slog.Logger
153+ runtimeObserver ports.RuntimeObserver
154+ baseEnv []string
155+ shell string
156+ shellArgs []string
157+ shellOverride bool
158+ persistEnabled bool
159+ catalogue ports.Catalogue
160+ catalogueRecords []domain.CatalogueRecord
161+ catalogueRecordsProvided bool
127 // snapshotRepository is the sole checkpoint storage contract.162 // snapshotRepository is the sole checkpoint storage contract.
128 snapshotRepository ports.SnapshotRepository163 snapshotRepository ports.SnapshotRepository
129 recovery *recoveryusecase.Coordinator164 recovery *recoveryusecase.Coordinator
@@ -656,7 +691,7 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
656 ephemeral := s.ephemeral691 ephemeral := s.ephemeral
657 s.mu.Unlock()692 s.mu.Unlock()
658 s.stopInMemoryLifecycle()693 s.stopInMemoryLifecycle()
659- if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline); err != nil {694+ if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline, nil); err != nil {
660 checkpointIncomplete = true695 checkpointIncomplete = true
661 d.log.Error("closing session with unpersisted terminal state", "err", err)696 d.log.Error("closing session with unpersisted terminal state", "err", err)
662 }697 }
@@ -918,21 +953,32 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
918 lease = rc.attachmentLease(ac)953 lease = rc.attachmentLease(ac)
919 }954 }
920 expected := ac.transportSnapshot()955 expected := ac.transportSnapshot()
921- if lease == nil || expected.transport != tr {956+ welcomeToken := sess.attachmentToken(ac, tr)
957+ welcomeToken.lease = lease
958+ welcomeTicket, admitted := ac.beginRoleEffect(welcomeToken)
959+ if lease == nil || expected.transport != tr || !admitted {
922 d.clientGone(sess, ac, tr, false)960 d.clientGone(sess, ac, tr, false)
923 return961 return
924 }962 }
925- if err := ac.sendExpectedTransport(expected, frameWelcome(sess, ac)); err != nil {963+ if err := ac.sendExpectedTransportForRole(expected, frameWelcome(sess, ac), welcomeTicket); err != nil {
964+ welcomeTicket.End()
926 d.clientGone(sess, ac, tr, false)965 d.clientGone(sess, ac, tr, false)
927 return966 return
928 }967 }
929 if !rc.markAttachmentReady(lease) {968 if !rc.markAttachmentReady(lease) {
969+ welcomeTicket.End()
930 // The attachment was displaced or detached while Welcome was in flight;970 // The attachment was displaced or detached while Welcome was in flight;
931 // never let this stale handshake emit an Output frame.971 // never let this stale handshake emit an Output frame.
932 d.clientGone(sess, ac, tr, false)972 d.clientGone(sess, ac, tr, false)
933 return973 return
934 }974 }
935- d.firstPaint(sess, ac, h.Size)975+ welcomeTicket.End()
976+ paintToken := sess.attachmentToken(ac, tr)
977+ paintToken.lease = lease
978+ if !d.firstPaintForTransition(paintToken) {
979+ d.clientGone(sess, ac, tr, false)
980+ return
981+ }
936 d.runConnLoop(ac)982 d.runConnLoop(ac)
937 _ = tr.Close()983 _ = tr.Close()
938 }984 }
@@ -946,23 +992,39 @@ type protoErr struct {
946 func (e *protoErr) Error() string { return e.text }992 func (e *protoErr) Error() string { return e.text }
947 993
948 // finishAttach completes an attachment prepared while d.mu is held. It994 // finishAttach completes an attachment prepared while d.mu is held. It
949-// publishes the terminal state before releasing d.mu, then queues replacement995+// publishes terminal and role state before releasing d.mu, then defers
950-// teardown so obsolete worker joins never delay the new handshake.996+// coordinator cleanup so obsolete workers never delay the new handshake.
951-func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) *attachedClient {997+func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) (*attachedClient, error) {
952 // Session state is the sole source for future PTY children. Update it before998 // Session state is the sole source for future PTY children. Update it before
953 // publishing the attachment; existing PTYs keep their original environment.999 // publishing the attachment; existing PTYs keep their original environment.
954 sess.mu.Lock()1000 sess.mu.Lock()
955 sess.env = copyEnvironment(h.Env)1001 sess.env = copyEnvironment(h.Env)
956 sess.terminal = term1002 sess.terminal = term
957 sess.mu.Unlock()1003 sess.mu.Unlock()
958- ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{1004+ opts := attachClientOptions{
959 clientID: h.ClientID,1005 clientID: h.ClientID,
960 resumeCapable: true,1006 resumeCapable: true,
961 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),1007 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
962- })1008+ }
1009+ ac := d.prepareAttachedClientLocked(tr, sz, opts)
963 d.mu.Unlock()1010 d.mu.Unlock()
964- d.retireReplacedClient(old, cleanup)1011+ result, err := d.transitionAttachment(attachmentTransitionRequest{
965- return ac1012+ target: sess,
1013+ next: ac,
1014+ expectedRole: attachmentDetached,
1015+ targetRole: attachmentActive,
1016+ expectedTransport: ac.transportSnapshot(),
1017+ ready: false,
1018+ })
1019+ if err != nil {
1020+ if tr != nil {
1021+ _ = tr.Close()
1022+ }
1023+ return nil, err
1024+ }
1025+ d.finishAttachedClient(sess, ac, opts)
1026+ d.deferAttachmentTransitionCleanups(result)
1027+ return ac, nil
966 }1028 }
967 1029
968 func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {1030 func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
@@ -1064,7 +1126,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1064 }1126 }
1065 }1127 }
1066 d.purgeParkedForSessionLocked(sess)1128 d.purgeParkedForSessionLocked(sess)
1067- return sess, d.finishAttach(sess, tr, sz, term, h), nil1129+ ac, err := d.finishAttach(sess, tr, sz, term, h)
1130+ return sess, ac, err
1068 1131
1069 case ports.IntentEphemeral:1132 case ports.IntentEphemeral:
1070 name := d.allocEphemeralNameLocked()1133 name := d.allocEphemeralNameLocked()
@@ -1074,7 +1137,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1074 return nil, nil, err1137 return nil, nil, err
1075 }1138 }
1076 d.purgeParkedForSessionLocked(sess)1139 d.purgeParkedForSessionLocked(sess)
1077- return sess, d.finishAttach(sess, tr, sz, term, h), nil1140+ ac, err := d.finishAttach(sess, tr, sz, term, h)
1141+ return sess, ac, err
1078 1142
1079 case ports.IntentNew:1143 case ports.IntentNew:
1080 if h.Name == "" {1144 if h.Name == "" {
@@ -1095,7 +1159,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1095 return nil, nil, err1159 return nil, nil, err
1096 }1160 }
1097 d.purgeParkedForSessionLocked(sess)1161 d.purgeParkedForSessionLocked(sess)
1098- return sess, d.finishAttach(sess, tr, sz, term, h), nil1162+ ac, err := d.finishAttach(sess, tr, sz, term, h)
1163+ return sess, ac, err
1099 1164
1100 case ports.IntentAttach:1165 case ports.IntentAttach:
1101 sess := d.findByNameLocked(h.Name)1166 sess := d.findByNameLocked(h.Name)
@@ -1114,7 +1179,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1114 }1179 }
1115 }1180 }
1116 d.purgeParkedForSessionLocked(sess)1181 d.purgeParkedForSessionLocked(sess)
1117- return sess, d.finishAttach(sess, tr, sz, term, h), nil1182+ ac, err := d.finishAttach(sess, tr, sz, term, h)
1183+ return sess, ac, err
1118 1184
1119 default:1185 default:
1120 d.mu.Unlock()1186 d.mu.Unlock()
M internal/usecase/daemon/floating.go +20 -3
@@ -160,15 +160,29 @@ func (d *Daemon) activateTab(sess *session, tb *tab) bool {
160 // activateTabAfterResize retains tab activation's warmup work while avoiding a160 // activateTabAfterResize retains tab activation's warmup work while avoiding a
161 // second resize when a synchronous outer resize request was already accepted.161 // second resize when a synchronous outer resize request was already accepted.
162 func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccepted bool) bool {162 func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccepted bool) bool {
163+ return d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, nil, nil)
164+}
165+
166+// activateTabAfterResizeForLease keeps transition-owned resize effects bound to
167+// the exact coordinator incarnation. A nil lease preserves direct/headless
168+// activation behavior.
169+func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerResizeAccepted bool, ac *attachedClient, lease *attachmentLease) bool {
163 if d == nil || sess == nil || tb == nil {170 if d == nil || sess == nil || tb == nil {
164 return false171 return false
165 }172 }
166 // A headless session has no actual terminal destination. Deferring warmup173 // A headless session has no actual terminal destination. Deferring warmup
167 // until firstPaint keeps restored tabs cold and avoids launching children174 // until firstPaint keeps restored tabs cold and avoids launching children
168 // merely because a tab was manipulated during teardown.175 // merely because a tab was manipulated during teardown.
169- sess.mu.Lock()176+ if ac == nil {
170- ac := sess.client177+ sess.mu.Lock()
171- sess.mu.Unlock()178+ ac = sess.client
179+ sess.mu.Unlock()
180+ } else {
181+ rc := sess.renderCoordinator()
182+ if rc == nil || !rc.leaseCurrent(lease, true) {
183+ return false
184+ }
185+ }
172 d.exitCopyMode(ac)186 d.exitCopyMode(ac)
173 if ac == nil {187 if ac == nil {
174 return false188 return false
@@ -182,6 +196,9 @@ func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccep
182 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}196 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
183 tb.mu.Unlock()197 tb.mu.Unlock()
184 if hasFloating {198 if hasFloating {
199+ if lease != nil {
200+ return d.requestTransactionalResizeForLease(sess, ac, lease, size, true)
201+ }
185 return d.requestTransactionalResize(sess, ac, size, true)202 return d.requestTransactionalResize(sess, ac, size, true)
186 }203 }
187 return false204 return false
M internal/usecase/daemon/input.go +80 -10
@@ -19,6 +19,13 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
19 d.handleInput(sess, ac, data)19 d.handleInput(sess, ac, data)
20 }20 }
21 21
22+func (d *Daemon) handleSequencedInputForRole(token attachmentRoleToken, _ uint64, data []byte) {
23+ if !token.activeEffect() {
24+ return
25+ }
26+ d.handleInputForRole(token, data)
27+}
28+
22 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {29 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
23 ac.initOverlays()30 ac.initOverlays()
24 ac.mouseScan.Scan(data,31 ac.mouseScan.Scan(data,
@@ -32,6 +39,30 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
32 )39 )
33 }40 }
34 41
42+func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) {
43+ ac := token.ac
44+ ac.initOverlays()
45+ ac.mouseScan.Scan(data,
46+ func(ev mouse.Event) {
47+ if token.activeEffect() {
48+ d.handleMouse(ac, ev)
49+ }
50+ },
51+ func(b []byte) {
52+ if !token.activeEffect() {
53+ return
54+ }
55+ if ac.overlays.HandleInput(d, b, token.effect) {
56+ return
57+ }
58+ if !token.activeEffect() {
59+ return
60+ }
61+ ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, roleToken: token})
62+ },
63+ )
64+}
65+
35 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {66 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
36 frameEvent := ev67 frameEvent := ev
37 ac.initOverlays()68 ac.initOverlays()
@@ -266,16 +297,49 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
266 }297 }
267 298
268 type daemonKeyHandler struct {299 type daemonKeyHandler struct {
269- d *Daemon300+ d *Daemon
270- ac *attachedClient301+ ac *attachedClient
271- actions daemonActionRunner302+ actions daemonActionRunner
303+ roleToken attachmentRoleToken
272 }304 }
273 305
274-func (h daemonKeyHandler) Forward(data []byte) {306+// acquireRoleEffect preserves a synchronous frame's existing admission and
307+// gives delayed router callbacks a fresh ticket for the exact captured role.
308+// Direct/headless callers without a role token retain their existing behavior.
309+func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool) {
310+ if h.roleToken.ac != nil {
311+ if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() {
312+ return h.roleToken.sess, effect, false
313+ }
314+ effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken)
315+ if h.d.afterDelayedKeyEffectAttempt != nil {
316+ h.d.afterDelayedKeyEffectAttempt(admitted)
317+ }
318+ if !admitted {
319+ return nil, nil, false
320+ }
321+ if h.d.afterRoleEffectAdmitted != nil {
322+ token := h.roleToken
323+ token.effect = effect
324+ h.d.afterRoleEffectAdmitted(token)
325+ }
326+ return h.roleToken.sess, effect, true
327+ }
275 sess := h.ac.currentSession()328 sess := h.ac.currentSession()
329+ if sess == nil || sess.attachmentRole(h.ac) == attachmentSnatched {
330+ return nil, nil, false
331+ }
332+ return sess, nil, false
333+}
334+
335+func (h daemonKeyHandler) Forward(data []byte) {
336+ sess, effect, owned := h.acquireRoleEffect()
276 if sess == nil {337 if sess == nil {
277 return338 return
278 }339 }
340+ if owned {
341+ defer effect.End()
342+ }
279 tb := sess.activeTab()343 tb := sess.activeTab()
280 if tb == nil {344 if tb == nil {
281 return345 return
@@ -287,10 +351,13 @@ func (h daemonKeyHandler) Forward(data []byte) {
287 }351 }
288 352
289 func (h daemonKeyHandler) Action(action keys.Action) {353 func (h daemonKeyHandler) Action(action keys.Action) {
290- sess := h.ac.currentSession()354+ sess, effect, owned := h.acquireRoleEffect()
291 if sess == nil {355 if sess == nil {
292 return356 return
293 }357 }
358+ if owned {
359+ defer effect.End()
360+ }
294 runResizeAction := func(request daemonActionRequest) {361 runResizeAction := func(request daemonActionRequest) {
295 request.target = resolveDaemonActionTarget(sess)362 request.target = resolveDaemonActionTarget(sess)
296 runner := h.actions363 runner := h.actions
@@ -309,7 +376,10 @@ func (h daemonKeyHandler) Action(action keys.Action) {
309 case keys.ActionOpenPalette:376 case keys.ActionOpenPalette:
310 h.d.enterPalette(sess, h.ac)377 h.d.enterPalette(sess, h.ac)
311 case keys.ActionJumpAttention:378 case keys.ActionJumpAttention:
312- if err := h.d.jumpAttention(sess, h.ac); err != nil {379+ effect.bindActionEnd(h.d, "jump-attention")
380+ token := h.roleToken
381+ token.effect = effect
382+ if err := h.d.jumpAttentionForRole(sess, h.ac, token); err != nil {
313 h.d.reportError(sess, err)383 h.d.reportError(sess, err)
314 }384 }
315 case keys.ActionToggleFloatingPane:385 case keys.ActionToggleFloatingPane:
@@ -318,19 +388,19 @@ func (h daemonKeyHandler) Action(action keys.Action) {
318 h.d.reportError(sess, err)388 h.d.reportError(sess, err)
319 }389 }
320 case keys.ActionFocusPaneLeft:390 case keys.ActionFocusPaneLeft:
321- if err := h.d.focusDir(sess, h.ac, layout.Left); err != nil {391+ if err := h.d.focusDir(sess, h.ac, layout.Left, effect); err != nil && err != errAttachmentTransition {
322 h.d.reportError(sess, err)392 h.d.reportError(sess, err)
323 }393 }
324 case keys.ActionFocusPaneRight:394 case keys.ActionFocusPaneRight:
325- if err := h.d.focusDir(sess, h.ac, layout.Right); err != nil {395+ if err := h.d.focusDir(sess, h.ac, layout.Right, effect); err != nil && err != errAttachmentTransition {
326 h.d.reportError(sess, err)396 h.d.reportError(sess, err)
327 }397 }
328 case keys.ActionFocusPaneUp:398 case keys.ActionFocusPaneUp:
329- if err := h.d.focusDir(sess, h.ac, layout.Up); err != nil {399+ if err := h.d.focusDir(sess, h.ac, layout.Up, effect); err != nil && err != errAttachmentTransition {
330 h.d.reportError(sess, err)400 h.d.reportError(sess, err)
331 }401 }
332 case keys.ActionFocusPaneDown:402 case keys.ActionFocusPaneDown:
333- if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil {403+ if err := h.d.focusDir(sess, h.ac, layout.Down, effect); err != nil && err != errAttachmentTransition {
334 h.d.reportError(sess, err)404 h.d.reportError(sess, err)
335 }405 }
336 case keys.ActionGrowPaneWidth:406 case keys.ActionGrowPaneWidth:
A internal/usecase/daemon/navigation_role_handoff_test.go +192 -0
@@ -0,0 +1,192 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/internal/usecase/picker"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
15+ tests := []struct {
16+ name string
17+ action string
18+ run func(*Daemon, *session, *session, *attachedClient, *roleEffectTicket) error
19+ check func(*testing.T, *Daemon, *session, *session)
20+ }{
21+ {
22+ name: "picker selection", action: "picker-select",
23+ run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error {
24+ d.enterPicker(source, ac)
25+ return d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select")
26+ },
27+ },
28+ {
29+ name: "palette session", action: "palette-session",
30+ run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *roleEffectTicket) error {
31+ return d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
32+ },
33+ },
34+ {
35+ name: "palette recent session", action: "palette-recent-session",
36+ run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error {
37+ return paletteExec{d: d, sess: source, ac: ac, recent: []recentSession{{id: target.id}}, effect: effect}.JumpRecentSession(1)
38+ },
39+ },
40+ {
41+ name: "session overflow", action: "overflow-session",
42+ run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error {
43+ source.mu.Lock()
44+ source.name = "alpha"
45+ source.mu.Unlock()
46+ target.mu.Lock()
47+ target.name = "charlie"
48+ target.mu.Unlock()
49+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
50+ return d.focusDir(source, ac, layout.Down, effect)
51+ },
52+ },
53+ {
54+ name: "stopped session resume", action: "stopped-session",
55+ run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *roleEffectTicket) error {
56+ d.mu.Lock()
57+ delete(d.sessions, target.id)
58+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, state: ports.SessionStopped}
59+ d.mu.Unlock()
60+ expectedCreatedAt := int64(9)
61+ return d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")
62+ },
63+ check: func(t *testing.T, d *Daemon, _, _ *session) {
64+ d.mu.Lock()
65+ _, stopped := d.stopped["stopped"]
66+ resumed := d.findByNameLocked("stopped")
67+ d.mu.Unlock()
68+ require.True(t, stopped)
69+ require.Nil(t, resumed)
70+ },
71+ },
72+ {
73+ name: "transition prompt creation", action: "create-session",
74+ run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *roleEffectTicket) error {
75+ return d.createSessionAndSwitchForRole(effect.roleToken(), "created")
76+ },
77+ check: func(t *testing.T, d *Daemon, _, _ *session) {
78+ d.mu.Lock()
79+ created := d.findByNameLocked("created")
80+ d.mu.Unlock()
81+ require.Nil(t, created, "stale prompt submission created a target session")
82+ },
83+ },
84+ }
85+
86+ for _, tt := range tests {
87+ t.Run(tt.name, func(t *testing.T) {
88+ d, source, old, _, releases := newManualTabSession(t, 2)
89+ defer releaseAll(releases)
90+ d.ptys = newFactorySeq(t)
91+ target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{
92+ newTab(nil, domain.Size{Cols: 80, Rows: 23}),
93+ newTab(nil, domain.Size{Cols: 80, Rows: 23}),
94+ }}
95+ d.mu.Lock()
96+ d.sessions[target.id] = target
97+ d.mu.Unlock()
98+
99+ rc := d.attachCoordinator(source, nil, old, true)
100+ token := source.attachmentToken(old, old.transport())
101+ token.lease = rc.attachmentLease(old)
102+ old.publishRoleCapability(token)
103+ effect, admitted := old.beginRoleEffect(token)
104+ require.True(t, admitted)
105+
106+ admissionEnded := make(chan struct{})
107+ releaseAction := make(chan struct{})
108+ d.afterActionRoleEffectEnded = func(action string) {
109+ if action == tt.action {
110+ close(admissionEnded)
111+ <-releaseAction
112+ }
113+ }
114+ actionDone := make(chan error, 1)
115+ go func() { actionDone <- tt.run(d, source, target, old, effect) }()
116+ select {
117+ case <-admissionEnded:
118+ case <-time.After(time.Second):
119+ t.Fatal("handoff did not release its role ticket")
120+ }
121+
122+ replacement, err := d.transitionAttachment(attachmentTransitionRequest{
123+ source: source, target: source, next: old,
124+ expectedRole: attachmentActive, targetRole: attachmentActive,
125+ expectedTransport: old.transportSnapshot(), ready: true,
126+ })
127+ require.NoError(t, err)
128+ close(releaseAction)
129+ require.Error(t, <-actionDone)
130+
131+ require.Same(t, source, old.currentSession())
132+ source.mu.Lock()
133+ require.Same(t, old, source.client)
134+ source.mu.Unlock()
135+ target.mu.Lock()
136+ require.Nil(t, target.client, "stale handoff attached to the target")
137+ require.Zero(t, target.active, "stale handoff changed target tab focus")
138+ target.mu.Unlock()
139+ if tt.check != nil {
140+ tt.check(t, d, source, target)
141+ }
142+ d.deferAttachmentTransitionCleanups(replacement)
143+ })
144+ }
145+}
146+
147+func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) {
148+ d, source, old, _ := newManualSessionWithPTYs(t, nil)
149+ d.mu.Lock()
150+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped}
151+ d.mu.Unlock()
152+
153+ rc := d.attachCoordinator(source, nil, old, true)
154+ token := source.attachmentToken(old, old.transport())
155+ token.lease = rc.attachmentLease(old)
156+ old.publishRoleCapability(token)
157+ effect, admitted := old.beginRoleEffect(token)
158+ require.True(t, admitted)
159+
160+ ended := make(chan struct{})
161+ release := make(chan struct{})
162+ d.afterActionRoleEffectEnded = func(action string) {
163+ if action == "picker-stopped" {
164+ close(ended)
165+ <-release
166+ }
167+ }
168+ expectedCreatedAt := int64(7)
169+ done := make(chan error, 1)
170+ go func() {
171+ done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped")
172+ }()
173+ <-ended
174+
175+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
176+ next.initOverlays()
177+ replacement, err := d.transitionAttachment(attachmentTransitionRequest{
178+ target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
179+ expectedTransport: next.transportSnapshot(), ready: true,
180+ })
181+ require.NoError(t, err)
182+ close(release)
183+ require.Error(t, <-done)
184+
185+ d.mu.Lock()
186+ _, stillStopped := d.stopped["stopped"]
187+ resumed := d.findByNameLocked("stopped")
188+ d.mu.Unlock()
189+ require.True(t, stillStopped)
190+ require.Nil(t, resumed)
191+ d.deferAttachmentTransitionCleanups(replacement)
192+}
A internal/usecase/daemon/navigation_role_incarnation_test.go +81 -0
@@ -0,0 +1,81 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/picker"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ rebind func(*testing.T, *Daemon, *session, *attachedClient)
15+ }{
16+ {
17+ name: "role generation and lease replaced on same attachment",
18+ rebind: func(t *testing.T, d *Daemon, source *session, ac *attachedClient) {
19+ result, err := d.transitionAttachment(attachmentTransitionRequest{
20+ source: source, target: source, next: ac,
21+ expectedRole: attachmentActive, targetRole: attachmentActive,
22+ expectedTransport: ac.transportSnapshot(), ready: true,
23+ })
24+ require.NoError(t, err)
25+ d.deferAttachmentTransitionCleanups(result)
26+ },
27+ },
28+ {
29+ name: "transport incarnation rebound on same attachment",
30+ rebind: func(t *testing.T, _ *Daemon, source *session, ac *attachedClient) {
31+ ac.replaceTransport(&closeTrackingTransport{})
32+ current := source.attachmentToken(ac, ac.transport())
33+ current.lease = source.renderCoordinator().attachmentLease(ac)
34+ ac.publishRoleCapability(current)
35+ },
36+ },
37+ }
38+
39+ for _, tt := range tests {
40+ t.Run(tt.name, func(t *testing.T) {
41+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
42+ target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{
43+ newTab(nil, domain.Size{Cols: 80, Rows: 23}),
44+ newTab(nil, domain.Size{Cols: 80, Rows: 23}),
45+ }}
46+ d.mu.Lock()
47+ d.sessions[target.id] = target
48+ d.mu.Unlock()
49+
50+ rc := d.attachCoordinator(source, nil, ac, true)
51+ token := source.attachmentToken(ac, ac.transport())
52+ token.lease = rc.attachmentLease(ac)
53+ ac.publishRoleCapability(token)
54+ effect, admitted := ac.beginRoleEffect(token)
55+ require.True(t, admitted)
56+
57+ ended := make(chan struct{})
58+ release := make(chan struct{})
59+ d.afterActionRoleEffectEnded = func(action string) {
60+ if action == "test-handoff" {
61+ close(ended)
62+ <-release
63+ }
64+ }
65+ done := make(chan error, 1)
66+ go func() {
67+ done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "test-handoff")
68+ }()
69+ <-ended
70+ tt.rebind(t, d, source, ac)
71+ close(release)
72+ require.ErrorIs(t, <-done, errAttachmentTransition)
73+
74+ require.Same(t, source, ac.currentSession())
75+ target.mu.Lock()
76+ require.Nil(t, target.client)
77+ require.Zero(t, target.active)
78+ target.mu.Unlock()
79+ })
80+ }
81+}
M internal/usecase/daemon/notify.go +5 -6
@@ -443,24 +443,23 @@ func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain.
443 443
444 // dismissToast removes only the specified visible toast. In particular, a444 // dismissToast removes only the specified visible toast. In particular, a
445 // link-connected event must not clear clipboard or other notice state.445 // link-connected event must not clear clipboard or other notice state.
446-func (d *Daemon) dismissToast(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) {446+func (d *Daemon) dismissToastWithoutRepaint(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) bool {
447 if ac == nil || ac.overlays == nil {447 if ac == nil || ac.overlays == nil {
448- return448+ return false
449 }449 }
450 rt := ac.overlays450 rt := ac.overlays
451 rt.noticeMu.Lock()451 rt.noticeMu.Lock()
452+ defer rt.noticeMu.Unlock()
452 i := rt.indexOfToastLocked(code, sid)453 i := rt.indexOfToastLocked(code, sid)
453 if i < 0 {454 if i < 0 {
454- rt.noticeMu.Unlock()455+ return false
455- return
456 }456 }
457 rt.noticeToasts[i].timer.stop()457 rt.noticeToasts[i].timer.stop()
458 rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...)458 rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...)
459 if len(rt.noticeToasts) == 0 {459 if len(rt.noticeToasts) == 0 {
460 rt.noticeOverflow = 0460 rt.noticeOverflow = 0
461 }461 }
462- rt.noticeMu.Unlock()462+ return true
463- d.repaintForNotice(ac)
464 }463 }
465 464
466 // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the465 // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the
M internal/usecase/daemon/notify_test.go +20 -24
@@ -71,23 +71,23 @@ func TestHandleClientNoticeSerializesReplacement(t *testing.T) {
71 tr, _ := newCapturingTransport(t)71 tr, _ := newCapturingTransport(t)
72 replaced := make(chan *attachedClient, 1)72 replaced := make(chan *attachedClient, 1)
73 go func() {73 go func() {
74- current, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})74+ current, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
75 replaced <- current75 replaced <- current
76 }()76 }()
77 77
78 // Releasing the mutation lets the already-validated notice publish before78 // Releasing the mutation lets the already-validated notice publish before
79- // replacement can own the session. Thus no stale attachment can mutate a79+ // replacement can own the session. Snatch cleanup then clears the displaced
80- // toast stack after sess.client changes.80+ // attachment's presentation state without moving the notice to the new one.
81 close(releaseMutation)81 close(releaseMutation)
82 <-noticeDone82 <-noticeDone
83 current := <-replaced83 current := <-replaced
84+ d.attachmentCleanupWg.Wait()
84 85
85 history := d.notices.history()86 history := d.notices.history()
86 require.Len(t, history, 1)87 require.Len(t, history, 1)
87 require.Equal(t, domain.NoticeClipboard, history[0].Code)88 require.Equal(t, domain.NoticeClipboard, history[0].Code)
88 oldToasts, _ := visibleToasts(old)89 oldToasts, _ := visibleToasts(old)
89- require.Len(t, oldToasts, 1)90+ require.Empty(t, oldToasts)
90- require.Equal(t, domain.NoticeClipboard, oldToasts[0].Code)
91 newToasts, _ := visibleToasts(current)91 newToasts, _ := visibleToasts(current)
92 require.Empty(t, newToasts)92 require.Empty(t, newToasts)
93 sess.mu.Lock()93 sess.mu.Lock()
@@ -97,10 +97,9 @@ func TestHandleClientNoticeSerializesReplacement(t *testing.T) {
97 97
98 func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {98 func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {
99 for _, tt := range []struct {99 for _, tt := range []struct {
100- name string100+ name string
101- setup func(*Daemon, *attachedClient, domain.SessionID)101+ setup func(*Daemon, *attachedClient, domain.SessionID)
102- notice ports.ClientNotice102+ notice ports.ClientNotice
103- wantToast domain.NoticeCode
104 }{103 }{
105 {104 {
106 name: "record",105 name: "record",
@@ -111,28 +110,23 @@ func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {
111 setup: func(d *Daemon, ac *attachedClient, sid domain.SessionID) {110 setup: func(d *Daemon, ac *attachedClient, sid domain.SessionID) {
112 d.showToast(ac, notice(domain.NoticeConnection, "connection degraded", sid))111 d.showToast(ac, notice(domain.NoticeConnection, "connection degraded", sid))
113 },112 },
114- notice: ports.ClientNotice{Action: ports.ClientNoticeLinkConnected},113+ notice: ports.ClientNotice{Action: ports.ClientNoticeLinkConnected},
115- wantToast: domain.NoticeConnection,
116 },114 },
117 } {115 } {
118 t.Run(tt.name, func(t *testing.T) {116 t.Run(tt.name, func(t *testing.T) {
119 d, sess, old, _ := newNoticeFixture(t, newNoticeClock())117 d, sess, old, _ := newNoticeFixture(t, newNoticeClock())
120- tr, _ := newCapturingTransport(t)
121- current, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
122 if tt.setup != nil {118 if tt.setup != nil {
123 tt.setup(d, old, sess.id)119 tt.setup(d, old, sess.id)
124 }120 }
121+ tr, _ := newCapturingTransport(t)
122+ current, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
123+ d.attachmentCleanupWg.Wait()
125 124
126 d.handleClientNotice(sess, old, tt.notice)125 d.handleClientNotice(sess, old, tt.notice)
127 126
128 require.Empty(t, d.notices.history(), "a replaced client must not record notices")127 require.Empty(t, d.notices.history(), "a replaced client must not record notices")
129 oldToasts, _ := visibleToasts(old)128 oldToasts, _ := visibleToasts(old)
130- if tt.setup == nil {129+ require.Empty(t, oldToasts, "snatch cleanup must remove displaced notice presentation")
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- }
136 currentToasts, _ := visibleToasts(current)130 currentToasts, _ := visibleToasts(current)
137 require.Empty(t, currentToasts)131 require.Empty(t, currentToasts)
138 })132 })
@@ -140,7 +134,8 @@ func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {
140 }134 }
141 135
142 func TestMalformedClientNoticeIsIgnored(t *testing.T) {136 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)
144 tr, ok := ac.tr.(*portsmocks.MockTransport)139 tr, ok := ac.tr.(*portsmocks.MockTransport)
145 require.True(t, ok, "attached client transport must be a MockTransport")140 require.True(t, ok, "attached client transport must be a MockTransport")
146 frames := []ports.Frame{141 frames := []ports.Frame{
@@ -757,17 +752,18 @@ func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) {
757 tr, _ := newCapturingTransport(t)752 tr, _ := newCapturingTransport(t)
758 replaced := make(chan *attachedClient, 1)753 replaced := make(chan *attachedClient, 1)
759 go func() {754 go func() {
760- ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})755+ ac, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
761 replaced <- ac756 replaced <- ac
762 }()757 }()
763 close(releaseRoute)758 close(releaseRoute)
764 <-globalDone759 <-globalDone
765 current := <-replaced760 current := <-replaced
761+ d.attachmentCleanupWg.Wait()
766 762
767- // Replacement cannot make old a stale delivery target: it publishes only763+ // Replacement publishes only after delivery, then snatch cleanup removes
768- // after the selected attachment has received the global toast.764+ // the displaced attachment's presentation state.
769 oldToasts, _ := visibleToasts(old)765 oldToasts, _ := visibleToasts(old)
770- require.Len(t, oldToasts, 1)766+ require.Empty(t, oldToasts)
771 newToasts, _ := visibleToasts(current)767 newToasts, _ := visibleToasts(current)
772 require.Empty(t, newToasts)768 require.Empty(t, newToasts)
773 sess.mu.Lock()769 sess.mu.Lock()
M internal/usecase/daemon/overlay_runtime.go +113 -8
@@ -32,10 +32,11 @@ type overlayRuntime struct {
32 paletteFeedback string32 paletteFeedback string
33 palettePending []byte33 palettePending []byte
34 34
35- promptMu sync.Mutex35+ promptMu sync.Mutex
36- prompt *promptui.Model36+ prompt *promptui.Model
37- promptSubmit func(string) error37+ promptSubmit func(string) error
38- promptPending []byte38+ promptTransitionSubmit func(string, attachmentRoleToken) error
39+ promptPending []byte
39 40
40 resizeMu sync.Mutex41 resizeMu sync.Mutex
41 resizeActive bool42 resizeActive bool
@@ -228,21 +229,125 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
228 return active || prePublication229 return active || prePublication
229 }230 }
230 231
231-func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {232+// clearForSnatch clears each overlay family independently. Every family
233+// revalidates the displaced role generation while holding only its own mutex,
234+// so cleanup can neither participate in cross-family lock inversion nor erase
235+// state published by a later promotion.
236+func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool {
237+ if token.ac == nil || token.ac.overlays == nil {
238+ return false
239+ }
240+ rt := token.ac.overlays
241+ current := func() bool {
242+ return token.ac.roleGeneration.Load() == token.generation && token.ac.transportSnapshotCurrent(token.transport)
243+ }
244+ afterFamily := func(family string) {
245+ if d.afterSnatchOverlayFamily != nil {
246+ d.afterSnatchOverlayFamily(family)
247+ }
248+ }
249+
250+ rt.pickerMu.Lock()
251+ if !current() {
252+ rt.pickerMu.Unlock()
253+ return false
254+ }
255+ previewSession := rt.pickerPreviewSession
256+ previewGeneration := rt.pickerPreviewGeneration
257+ rt.picker = nil
258+ rt.pickerPending = nil
259+ rt.pickerESC.stop()
260+ rt.pickerPreviewGeneration++
261+ rt.pickerPreview = nil
262+ rt.pickerPreviewSession = nil
263+ rt.pickerMu.Unlock()
264+ afterFamily("picker")
265+ // Subscription teardown may enter a coordinator and therefore runs outside
266+ // every overlay-family lock.
267+ d.teardownPreviewSubscription(token.ac, previewSession, previewGeneration)
268+
269+ rt.paletteMu.Lock()
270+ if !current() {
271+ rt.paletteMu.Unlock()
272+ return false
273+ }
274+ token.ac.clearPaletteLocked()
275+ rt.paletteMu.Unlock()
276+ afterFamily("palette")
277+
278+ rt.promptMu.Lock()
279+ if !current() {
280+ rt.promptMu.Unlock()
281+ return false
282+ }
283+ rt.prompt = nil
284+ rt.promptSubmit = nil
285+ rt.promptTransitionSubmit = nil
286+ rt.promptPending = nil
287+ rt.promptMu.Unlock()
288+ afterFamily("prompt")
289+
290+ rt.resizeMu.Lock()
291+ if !current() {
292+ rt.resizeMu.Unlock()
293+ return false
294+ }
295+ rt.resizeActive = false
296+ rt.resizePending = nil
297+ rt.resizeESC.stop()
298+ rt.resizeMu.Unlock()
299+ afterFamily("resize")
300+
301+ rt.copyMu.Lock()
302+ if !current() {
303+ rt.copyMu.Unlock()
304+ return false
305+ }
306+ d.stopCopyPendingTimerLocked(token.ac)
307+ rt.copyPending = nil
308+ rt.clearCopyModeLocked()
309+ rt.statusFeedback = ""
310+ rt.copyMu.Unlock()
311+ afterFamily("copy")
312+
313+ rt.noticeMu.Lock()
314+ if !current() {
315+ rt.noticeMu.Unlock()
316+ return false
317+ }
318+ for i := range rt.noticeToasts {
319+ rt.noticeToasts[i].timer.stop()
320+ }
321+ rt.noticeToasts = nil
322+ rt.noticeOverflow = 0
323+ rt.noticeSeq++
324+ rt.noticesOverlay = nil
325+ rt.noticesPending = nil
326+ rt.noticesESC.stop()
327+ rt.noticeMu.Unlock()
328+ afterFamily("notice")
329+ return true
330+}
331+
332+func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*roleEffectTicket) bool {
232 if rt == nil || rt.ac == nil {333 if rt == nil || rt.ac == nil {
233 return false334 return false
234 }335 }
235 ac := rt.ac336 ac := rt.ac
337+ var effect *roleEffectTicket
338+ if len(effects) != 0 {
339+ effect = effects[0]
340+ }
236 if rt.promptActive() {341 if rt.promptActive() {
237- d.handlePromptInput(ac, data)342+ d.handlePromptInput(ac, data, effect)
238 return true343 return true
239 }344 }
240 if rt.paletteActive() {345 if rt.paletteActive() {
241- d.handlePaletteInput(ac, data)346+ d.handlePaletteInput(ac, data, effect)
242 return true347 return true
243 }348 }
244 if rt.pickerActive() {349 if rt.pickerActive() {
245- d.handlePickerInput(ac, data)350+ d.handlePickerInput(ac, data, effect)
246 return true351 return true
247 }352 }
248 if rt.noticesActive() {353 if rt.noticesActive() {
M internal/usecase/daemon/palette.go +65 -8
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "errors"
4 "sort"5 "sort"
5 "time"6 "time"
6 7
@@ -147,7 +148,7 @@ func (d *Daemon) recordPaletteUse(code string) {
147 d.paletteRecent = recent148 d.paletteRecent = recent
148 }149 }
149 150
150-func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {151+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
151 sess := ac.currentSession()152 sess := ac.currentSession()
152 if sess == nil {153 if sess == nil {
153 return154 return
@@ -160,6 +161,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
160 var generation uint64161 var generation uint64
161 var rawQuery string162 var rawQuery string
162 changed, cancel, execute := false, false, false163 changed, cancel, execute := false, false, false
164+ var effect *roleEffectTicket
165+ if len(effects) != 0 {
166+ effect = effects[0]
167+ }
163 168
164 ac.overlays.paletteMu.Lock()169 ac.overlays.paletteMu.Lock()
165 if ac.overlays.palette == nil {170 if ac.overlays.palette == nil {
@@ -270,7 +275,16 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
270 } else {275 } else {
271 target.Stopped = true276 target.Stopped = true
272 }277 }
273- if err := d.switchToTarget(sess, ac, target); err != nil {278+ var err error
279+ if effect != nil {
280+ err = d.switchToTargetForRole(effect.roleToken(), target, sessionHandoffGuard{}, "palette-session")
281+ } else {
282+ err = d.switchToTarget(sess, ac, target)
283+ }
284+ if effect != nil && errors.Is(err, errAttachmentTransition) {
285+ return
286+ }
287+ if err != nil {
274 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")288 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
275 d.invalidateRender(sess, ac, true, "palette.go")289 d.invalidateRender(sess, ac, true, "palette.go")
276 return290 return
@@ -288,8 +302,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
288 d.invalidateRender(sess, ac, true, "palette.go")302 d.invalidateRender(sess, ac, true, "palette.go")
289 return303 return
290 }304 }
291- exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}305+ exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent, effect: effect}
292 if err := exec.JumpRecentSession(rank); err != nil {306 if err := exec.JumpRecentSession(rank); err != nil {
307+ if errors.Is(err, errAttachmentTransition) {
308+ return
309+ }
293 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")310 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
294 d.invalidateRender(sess, ac, true, "palette.go")311 d.invalidateRender(sess, ac, true, "palette.go")
295 return312 return
@@ -300,12 +317,30 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
300 }317 }
301 return318 return
302 }319 }
303- if !ac.closeExecutedPalette(generation, rawQuery) {320+ roleHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
321+ if !roleHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
304 return322 return
305 }323 }
306 sess.dispatchMu.Lock()324 sess.dispatchMu.Lock()
307- err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args)325+ err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect}, args)
308 sess.dispatchMu.Unlock()326 sess.dispatchMu.Unlock()
327+ if errors.Is(err, errAttachmentTransition) {
328+ return
329+ }
330+ if err == nil && roleHandoff {
331+ if current := ac.currentSession(); current != nil {
332+ currentToken := current.attachmentToken(ac, ac.transport())
333+ fresh, admitted := ac.beginRoleEffect(currentToken)
334+ if admitted {
335+ if ac.closeExecutedPalette(generation, rawQuery) {
336+ d.invalidateRender(current, ac, true, "palette.go")
337+ }
338+ fresh.End()
339+ }
340+ } else {
341+ ac.closeExecutedPalette(generation, rawQuery)
342+ }
343+ }
309 if err != nil {344 if err != nil {
310 d.log.Error("palette command failed", "err", err, "command", cmd.Code)345 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
311 d.reportError(sess, err)346 d.reportError(sess, err)
@@ -356,6 +391,7 @@ type paletteExec struct {
356 ac *attachedClient391 ac *attachedClient
357 recent []recentSession392 recent []recentSession
358 actions daemonActionRunner393 actions daemonActionRunner
394+ effect *roleEffectTicket
359 }395 }
360 396
361 func (e paletteExec) runAction(request daemonActionRequest) error {397 func (e paletteExec) runAction(request daemonActionRequest) error {
@@ -378,8 +414,11 @@ func (e paletteExec) CreateTab() error {
378 }414 }
379 415
380 func (e paletteExec) CreateSession() error {416 func (e paletteExec) CreateSession() error {
381- e.d.enterPrompt(e.sess, e.ac, " Create session ", "", func(name string) error {417+ e.d.enterTransitionPrompt(e.sess, e.ac, " Create session ", "", func(name string, token attachmentRoleToken) error {
382- return e.d.createSessionAndSwitch(e.sess, e.ac, name)418+ if token.ac == nil {
419+ return e.d.createSessionAndSwitch(e.sess, e.ac, name)
420+ }
421+ return e.d.createSessionAndSwitchForRole(token, name)
383 })422 })
384 return nil423 return nil
385 }424 }
@@ -443,11 +482,20 @@ func (e paletteExec) ToggleFloatingPane() error {
443 }482 }
444 483
445 func (e paletteExec) BackSession() error {484 func (e paletteExec) BackSession() error {
485+ if e.effect != nil {
486+ return e.d.backSessionForRole(e.effect.roleToken())
487+ }
446 e.d.backSession(e.sess, e.ac)488 e.d.backSession(e.sess, e.ac)
447 return nil489 return nil
448 }490 }
449 491
450 func (e paletteExec) Detach() error {492 func (e paletteExec) Detach() error {
493+ if e.effect != nil {
494+ if !e.d.clientGoneForRole(e.effect.roleToken(), true) {
495+ return errAttachmentTransition
496+ }
497+ return nil
498+ }
451 e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)499 e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)
452 return nil500 return nil
453 }501 }
@@ -515,7 +563,16 @@ func (e paletteExec) JumpRecentSession(rank int) error {
515 if e.d.beforeRecentSessionHandoff != nil {563 if e.d.beforeRecentSessionHandoff != nil {
516 e.d.beforeRecentSessionHandoff()564 e.d.beforeRecentSessionHandoff()
517 }565 }
518- if err := e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}); err != nil {566+ var err error
567+ if e.effect != nil {
568+ err = e.d.switchToTargetForRole(e.effect.roleToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")
569+ } else {
570+ err = e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1})
571+ }
572+ if e.effect != nil && errors.Is(err, errAttachmentTransition) {
573+ return err
574+ }
575+ if err != nil {
519 return command.ErrInvalidArguments576 return command.ErrInvalidArguments
520 }577 }
521 return nil578 return nil
M internal/usecase/daemon/palette_test.go +17 -4
@@ -287,12 +287,19 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
287 287
288 d.handleInput(current, ac, []byte("\x1b "))288 d.handleInput(current, ac, []byte("\x1b "))
289 awaitFrame(t, sends, ports.MsgOutput)289 awaitFrame(t, sends, ports.MsgOutput)
290+ generation := ac.roleGeneration.Load()
290 d.handleInput(current, ac, []byte("stopped\r"))291 d.handleInput(current, ac, []byte("stopped\r"))
291 292
292- require.Equal(t, "stopped", ac.currentSession().name)293+ resumed := ac.currentSession()
293- require.Equal(t, int64(42), ac.currentSession().createdAt)294+ require.Equal(t, "stopped", resumed.name)
295+ require.Equal(t, int64(42), resumed.createdAt)
296+ require.Equal(t, attachmentActive, resumed.attachmentRole(ac))
297+ require.Greater(t, ac.roleGeneration.Load(), generation, "stopped-session handoff must publish through the attachment transition")
294 require.False(t, ac.overlays.paletteActive())298 require.False(t, ac.overlays.paletteActive())
295- awaitFrame(t, sends, ports.MsgOutput)299+ firstPaint := awaitFrame(t, sends, ports.MsgOutput)
300+ firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)
301+ require.NoError(t, err)
302+ require.Zero(t, firstOutput.BaseStateNum, "stopped-session first paint must reset moving output state")
296 awaitFrame(t, sends, ports.MsgOutput)303 awaitFrame(t, sends, ports.MsgOutput)
297 }304 }
298 305
@@ -530,11 +537,15 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
530 require.True(t, ac.overlays.promptActive())537 require.True(t, ac.overlays.promptActive())
531 require.Contains(t, string(promptOutput.Data), "Create session")538 require.Contains(t, string(promptOutput.Data), "Create session")
532 539
540+ generation := ac.roleGeneration.Load()
533 d.handleInput(sess, ac, []byte("scratch\r"))541 d.handleInput(sess, ac, []byte("scratch\r"))
534 // The submit first paints the newly attached session while the prompt is542 // The submit first paints the newly attached session while the prompt is
535 // still open, then handlePromptInput closes the prompt and repaints the543 // still open, then handlePromptInput closes the prompt and repaints the
536 // client's current session. The final frame must be for the new session.544 // client's current session. The final frame must be for the new session.
537- awaitFrame(t, sends, ports.MsgOutput)545+ firstPaint := awaitFrame(t, sends, ports.MsgOutput)
546+ firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)
547+ require.NoError(t, err)
548+ require.Zero(t, firstOutput.BaseStateNum, "new-session first paint must reset moving output state")
538 finalRepaint := awaitFrame(t, sends, ports.MsgOutput)549 finalRepaint := awaitFrame(t, sends, ports.MsgOutput)
539 finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload)550 finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload)
540 require.NoError(t, err)551 require.NoError(t, err)
@@ -547,6 +558,8 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
547 require.Equal(t, "scratch", newSess.name)558 require.Equal(t, "scratch", newSess.name)
548 require.False(t, newSess.ephemeral)559 require.False(t, newSess.ephemeral)
549 require.Same(t, ac, newSess.client)560 require.Same(t, ac, newSess.client)
561+ require.Equal(t, attachmentActive, newSess.attachmentRole(ac))
562+ require.Greater(t, ac.roleGeneration.Load(), generation, "new-session handoff must publish through the attachment transition")
550 require.Contains(t, string(finalOutput.Data), "scratch")563 require.Contains(t, string(finalOutput.Data), "scratch")
551 require.NotContains(t, string(finalOutput.Data), "Create session")564 require.NotContains(t, string(finalOutput.Data), "Create session")
552 }565 }
M internal/usecase/daemon/pane_actions.go +4 -1
@@ -254,7 +254,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
254 return nil254 return nil
255 }255 }
256 256
257-func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction) error {257+func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effects ...*roleEffectTicket) error {
258 target := resolveDaemonActionTarget(sess)258 target := resolveDaemonActionTarget(sess)
259 oldFocus := layout.PaneID("")259 oldFocus := layout.PaneID("")
260 if target.pane != nil {260 if target.pane != nil {
@@ -279,6 +279,9 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
279 if ac == nil {279 if ac == nil {
280 return errNoNeighbor280 return errNoNeighbor
281 }281 }
282+ if len(effects) != 0 && effects[0] != nil {
283+ return d.switchToTargetForRole(effects[0].roleToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
284+ }
282 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)285 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
283 }286 }
284 287
M internal/usecase/daemon/picker.go +259 -107
@@ -3,6 +3,7 @@ package daemon
3 3
4 import (4 import (
5 "context"5 "context"
6+ "errors"
6 "sort"7 "sort"
7 8
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
@@ -117,7 +118,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
117 }118 }
118 }119 }
119 120
120-func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {121+func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
121 sess := ac.currentSession()122 sess := ac.currentSession()
122 if sess == nil {123 if sess == nil {
123 return124 return
@@ -148,11 +149,23 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
148 rt.pickerMu.Unlock()149 rt.pickerMu.Unlock()
149 150
150 if result.action == 'x' {151 if result.action == 'x' {
152+ var effect *roleEffectTicket
153+ if len(effects) != 0 {
154+ effect = effects[0]
155+ }
156+ effect.bindActionEnd(d, "picker-delete")
151 if ok {157 if ok {
152- if err := d.killPickerTarget(target); err != nil {158+ if err := d.killPickerTargetForRole(target, effect.roleToken()); err != nil {
153 d.reportError(sess, err)159 d.reportError(sess, err)
154 }160 }
155 }161 }
162+ if effect != nil && effect.ended.Load() {
163+ fresh, admitted := ac.beginRoleEffect(effect.token)
164+ if !admitted {
165+ return
166+ }
167+ defer fresh.End()
168+ }
156 d.refreshPicker(ac)169 d.refreshPicker(ac)
157 d.invalidateRender(sess, ac, true, "picker.go")170 d.invalidateRender(sess, ac, true, "picker.go")
158 return171 return
@@ -160,12 +173,24 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
160 if result.changed {173 if result.changed {
161 d.registerPreviewForSelection(ac)174 d.registerPreviewForSelection(ac)
162 }175 }
163- if result.exit {176+ navigating := (result.action == '\r' || result.action == '\n') && ok
177+ if result.exit && !navigating {
164 d.closePicker(ac)178 d.closePicker(ac)
165 }179 }
166- if (result.action == '\r' || result.action == '\n') && ok {180+ if navigating {
167- if err := d.switchToTarget(sess, ac, target); err != nil {181+ var err error
182+ if len(effects) != 0 && effects[0] != nil {
183+ err = d.switchToTargetForRole(effects[0].roleToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
184+ } else {
185+ d.closePicker(ac)
186+ err = d.switchToTarget(sess, ac, target)
187+ }
188+ if errors.Is(err, errAttachmentTransition) {
189+ return
190+ }
191+ if err != nil {
168 d.reportError(sess, err)192 d.reportError(sess, err)
193+ return
169 }194 }
170 return195 return
171 }196 }
@@ -347,6 +372,43 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
347 // command switching behavior.372 // command switching behavior.
348 type sessionHandoffGuard struct {373 type sessionHandoffGuard struct {
349 expectedSource *tab374 expectedSource *tab
375+ closePicker bool
376+}
377+
378+// switchActiveTargetForRole hands a frame-bound navigation request to the
379+// centralized transition. The transition releases admission at its freeze seam
380+// and revalidates the exact initiating token before changing target focus or
381+// attachment ownership.
382+func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error {
383+ if token.sess == nil || token.ac == nil || token.role != attachmentActive {
384+ return nil
385+ }
386+ d.mu.Lock()
387+ targetSess := d.sessions[target.Session]
388+ d.mu.Unlock()
389+ if targetSess == nil || targetSess == token.sess {
390+ return nil
391+ }
392+
393+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
394+ source: token.sess, target: targetSess, next: token.ac,
395+ expectedRole: attachmentActive, targetRole: attachmentActive,
396+ expectedTransport: token.transport, sourceToken: &token, action: "jump-attention",
397+ activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
398+ })
399+ if err != nil {
400+ // Losing the exact source role is a benign stale action, not a notice for
401+ // whichever attachment replaced the initiator.
402+ if !token.activeCurrent() {
403+ return nil
404+ }
405+ return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
406+ }
407+ d.touchMRU(targetSess)
408+ token.ac.recordPreviousSession(token.sess)
409+ d.deferAttachmentTransitionCleanups(transition)
410+ d.firstPaintForTransition(transition.published)
411+ return nil
350 }412 }
351 413
352 // switchToTarget resolves named lifecycle targets and commits their transition414 // switchToTarget resolves named lifecycle targets and commits their transition
@@ -356,15 +418,34 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
356 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})418 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
357 }419 }
358 420
359-// switchToTargetGuarded is the navigation-only variant whose guard is checked421+// switchToTargetForRole is the sole client-originated navigation entry. The
360-// as part of the source ownership transfer.422+// intent captures the exact initiating capability before admission is released;
423+// every active, stopped, and same-session target then uses transitionAttachment
424+// for frozen, atomic source-token preflight.
425+func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {
426+ if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.effect == nil {
427+ return nil
428+ }
429+ token.effect.bindActionEnd(d, action)
430+ token.effect.End()
431+ return d.switchToTargetGuardedForRole(token.sess, token.ac, target, guard, &token, action)
432+}
433+
434+// switchToTargetGuarded is retained for daemon-internal and headless callers.
361 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {435 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
436+ return d.switchToTargetGuardedForRole(from, ac, target, guard, nil, "")
437+}
438+
439+func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentRoleToken, action string) error {
362 if target.Name != "" {440 if target.Name != "" {
363 ctx := d.serveCtx441 ctx := d.serveCtx
364 if ctx == nil {442 if ctx == nil {
365 ctx = context.Background()443 ctx = context.Background()
366 }444 }
367 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {445 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
446+ if sourceToken != nil && !sourceToken.activeCurrent() {
447+ return errAttachmentTransition
448+ }
368 d.invalidateRender(from, ac, true, "picker.go")449 d.invalidateRender(from, ac, true, "picker.go")
369 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)450 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
370 }451 }
@@ -373,8 +454,7 @@ func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target
373 d.mu.Lock()454 d.mu.Lock()
374 var (455 var (
375 targetSess *session456 targetSess *session
376- old *attachedClient457+ transition attachmentTransitionResult
377- cleanups []renderLifecycleCleanup
378 switched bool458 switched bool
379 cause error459 cause error
380 )460 )
@@ -382,39 +462,56 @@ func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target
382 active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)462 active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
383 if ok {463 if ok {
384 if isStopped {464 if isStopped {
385- targetSess, cleanups, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)465+ targetSess, transition, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, sourceToken, guard, action)
386 } else {466 } else {
387 // The snapshot ID may describe the stopped representation. The467 // The snapshot ID may describe the stopped representation. The
388 // locked name/lifecycle resolver chose this active incarnation.468 // locked name/lifecycle resolver chose this active incarnation.
389 resolvedTarget := target469 resolvedTarget := target
390 resolvedTarget.Session = active.id470 resolvedTarget.Session = active.id
391- targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard)471+ targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action)
392 }472 }
393 }473 }
394 } else if active := d.sessions[target.Session]; active != nil {474 } else if active := d.sessions[target.Session]; active != nil {
395- targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard)475+ targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action)
396 }476 }
397 d.mu.Unlock()477 d.mu.Unlock()
398 478
399 if !switched {479 if !switched {
480+ if sourceToken != nil && !sourceToken.activeCurrent() {
481+ return errAttachmentTransition
482+ }
400 if guard.expectedSource != nil {483 if guard.expectedSource != nil {
401 return errNoNeighbor484 return errNoNeighbor
402 }485 }
403 d.invalidateRender(from, ac, true, "picker.go")486 d.invalidateRender(from, ac, true, "picker.go")
404 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)487 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
405 }488 }
406- finishRenderLifecycleCleanups(cleanups)489+ if guard.closePicker {
407- if old != nil && old != ac {490+ if sourceToken != nil {
408- d.unregisterPreview(old)491+ fresh, admitted := ac.beginRoleEffect(transition.published)
409- old.clearPreviousSession()492+ if admitted {
410- old.setSession(nil)493+ d.closePicker(ac)
411- d.notifyDetachedAsync(old, ports.ReasonReplaced)494+ fresh.End()
495+ }
496+ } else {
497+ d.closePicker(ac)
498+ }
412 }499 }
500+ d.deferAttachmentTransitionCleanups(transition)
413 if targetSess == from {501 if targetSess == from {
414- d.activateTab(from, from.activeTab())502+ if sourceToken != nil {
415- d.invalidateRender(from, ac, true, "picker.go")503+ fresh, admitted := ac.beginRoleEffect(transition.published)
504+ if admitted {
505+ d.activateTab(from, from.activeTab())
506+ d.invalidateRender(from, ac, true, "picker.go")
507+ fresh.End()
508+ }
509+ } else {
510+ d.activateTab(from, from.activeTab())
511+ d.invalidateRender(from, ac, true, "picker.go")
512+ }
416 } else {513 } else {
417- d.firstPaint(targetSess, ac, ac.size)514+ d.firstPaintForTransition(transition.published)
418 }515 }
419 return nil516 return nil
420 }517 }
@@ -439,121 +536,154 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
439 return nil, stopped, true, true536 return nil, stopped, true, true
440 }537 }
441 538
442-// switchToActiveTargetLocked commits an active target handoff. Caller holds539+// switchToActiveTargetLocked commits an active target handoff through the
443-// d.mu and releases it before completing render cleanup or first paint.540+// centralized attachment transition. Caller holds d.mu.
444-func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard) (*session, *attachedClient, []renderLifecycleCleanup, bool) {541+func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentRoleToken, action string) (*session, attachmentTransitionResult, 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()
449 if d.sessions[target.Session] != targetSess {542 if d.sessions[target.Session] != targetSess {
450- return nil, nil, nil, false543+ return nil, attachmentTransitionResult{}, false
451 }544 }
452 if targetSess == from {545 if targetSess == from {
453- targetSess.mu.Lock()546+ if sourceToken == nil {
454- defer targetSess.mu.Unlock()547+ targetSess.mu.Lock()
455- if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {548+ defer targetSess.mu.Unlock()
456- return nil, nil, nil, false549+ if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
550+ return nil, attachmentTransitionResult{}, false
551+ }
552+ targetSess.active = target.TabIndex
553+ return targetSess, attachmentTransitionResult{}, true
457 }554 }
458- targetSess.active = target.TabIndex555+ d.mu.Unlock()
459- return targetSess, nil, nil, true556+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
557+ source: from, target: targetSess, next: ac,
558+ expectedRole: attachmentActive, targetRole: attachmentActive,
559+ expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
560+ activateTargetTab: true, targetTabIndex: target.TabIndex, preserveRole: true,
561+ })
562+ d.mu.Lock()
563+ return targetSess, transition, err == nil
460 }564 }
461 565
462- targetSess.mu.Lock()566+ unlock := lockAttachmentSessions(from, targetSess)
463- matches := targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt)567+ if from.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {
464- if !matches {568+ unlock()
465- targetSess.mu.Unlock()569+ return nil, attachmentTransitionResult{}, false
466- return nil, nil, nil, false
467 }570 }
468- from.mu.Lock()
469- if from.client != ac {
470- from.mu.Unlock()
471- targetSess.mu.Unlock()
472- return nil, nil, nil, false
473- }
474- var guardedSource *tab
475 if guard.expectedSource != nil {571 if guard.expectedSource != nil {
476 if from.active < 0 || from.active >= len(from.tabs) || from.tabs[from.active] != guard.expectedSource {572 if from.active < 0 || from.active >= len(from.tabs) || from.tabs[from.active] != guard.expectedSource {
477- from.mu.Unlock()573+ unlock()
478- targetSess.mu.Unlock()574+ return nil, attachmentTransitionResult{}, false
479- return nil, nil, nil, false
480 }575 }
481- guardedSource = guard.expectedSource576+ guard.expectedSource.mu.Lock()
482- guardedSource.mu.Lock()577+ sourceHasFloating := guard.expectedSource.floating.state == floatingVisible
483- if guardedSource.floating.state == floatingVisible {578+ guard.expectedSource.mu.Unlock()
484- guardedSource.mu.Unlock()579+ if sourceHasFloating {
485- from.mu.Unlock()580+ unlock()
486- targetSess.mu.Unlock()581+ return nil, attachmentTransitionResult{}, false
487- return nil, nil, nil, false
488 }582 }
489 }583 }
490- term := from.terminal584+ unlock()
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()
498 585
499- old := targetSess.client586+ // Release d.mu before the gate freeze/drain. transitionAttachment reacquires
500- targetSess.terminal = term587+ // d.mu and revalidates both registered lifecycles for publication.
501- targetSess.env = copyEnvironment(env)588+ d.mu.Unlock()
502- if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {589+ expectedTransport := ac.transportSnapshot()
503- targetSess.active = target.TabIndex590+ if sourceToken != nil {
504- }591+ expectedTransport = sourceToken.transport
505- targetSess.mu.Unlock()592+ }
506-593+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
507- // Prepare source invalidation, output rebase, and destination coordinator594+ source: from,
508- // identity before publishing the destination attachment.595+ target: targetSess,
509- cleanups := d.handoffCoordinator(from, targetSess, old, ac)596+ next: ac,
510- ac.setSession(targetSess)597+ expectedRole: attachmentActive,
511- targetSess.mu.Lock()598+ targetRole: attachmentActive,
512- targetSess.client = ac599+ expectedTransport: expectedTransport,
513- targetSess.mu.Unlock()600+ sourceToken: sourceToken,
601+ action: action,
602+ expectedSourceTab: guard.expectedSource,
603+ activateTargetTab: target.TabIndex >= 0,
604+ targetTabIndex: target.TabIndex,
605+ copySourceEnvironment: true,
606+ ready: true,
607+ })
608+ d.mu.Lock()
609+ if err != nil {
610+ return nil, attachmentTransitionResult{}, false
611+ }
514 d.touchMRU(targetSess)612 d.touchMRU(targetSess)
515 ac.recordPreviousSession(from)613 ac.recordPreviousSession(from)
516- return targetSess, old, cleanups, true614+ return targetSess, transition, true
517 }615 }
518 616
519 // resumeStoppedAndSwitchLocked creates the stopped representation and commits617 // resumeStoppedAndSwitchLocked creates the stopped representation and commits
520 // the handoff while d.mu is held. Creation failure leaves the source client618 // the handoff while d.mu is held. Creation failure leaves the source client
521 // and stopped record untouched.619 // and stopped record untouched.
522-func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) {620+func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession, sourceToken *attachmentRoleToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
523 if stopped.record.Name != "" && stopped.state == ports.SessionBroken {621 if stopped.record.Name != "" && stopped.state == ports.SessionBroken {
524- return nil, nil, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}622+ return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
623+ }
624+
625+ if sourceToken != nil {
626+ var targetSess *session
627+ d.mu.Unlock()
628+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
629+ source: from, next: ac, expectedRole: attachmentActive, targetRole: attachmentActive,
630+ expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
631+ expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
632+ createTargetLocked: func() (*session, error) {
633+ current, ok := d.stopped[target.Name]
634+ if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt) {
635+ return nil, errAttachmentTransition
636+ }
637+ from.mu.Lock()
638+ cwd, term, env := d.dirOrHome(current.cwd), from.terminal, copyEnvironment(from.env)
639+ from.mu.Unlock()
640+ created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, current.tabNames)
641+ targetSess = created
642+ return created, createErr
643+ },
644+ })
645+ d.mu.Lock()
646+ if err != nil {
647+ if targetSess != nil {
648+ d.mu.Unlock()
649+ _ = d.killSession(targetSess, ports.ReasonSessionKilled, true)
650+ d.mu.Lock()
651+ }
652+ return targetSess, attachmentTransitionResult{}, false, err
653+ }
654+ d.touchMRU(targetSess)
655+ ac.recordPreviousSession(from)
656+ return targetSess, transition, true, nil
525 }657 }
526 658
527- // The caller already holds d.mu. Keep handoff ownership atomic with global
528- // routing so it cannot select an attachment midway between sessions.
529- d.notices.routingMu.Lock()
530- defer d.notices.routingMu.Unlock()
531 from.mu.Lock()659 from.mu.Lock()
532 if from.client != ac {660 if from.client != ac {
533 from.mu.Unlock()661 from.mu.Unlock()
534- return nil, nil, false, nil662+ return nil, attachmentTransitionResult{}, false, nil
535 }663 }
536 term := from.terminal664 term := from.terminal
537 env := copyEnvironment(from.env)665 env := copyEnvironment(from.env)
666+ from.mu.Unlock()
538 cwd := d.dirOrHome(stopped.cwd)667 cwd := d.dirOrHome(stopped.cwd)
539 targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames)668 targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames)
540 if err != nil {669 if err != nil {
541- from.mu.Unlock()
542 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)670 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
543- return nil, nil, false, err671+ return nil, attachmentTransitionResult{}, false, err
544 }672 }
545- from.client = nil
546- ac.setSession(nil)
547- from.mu.Unlock()
548 673
549- cleanups := d.handoffCoordinator(from, targetSess, nil, ac)674+ d.mu.Unlock()
550- ac.setSession(targetSess)675+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
551- targetSess.mu.Lock()676+ source: from, target: targetSess, next: ac,
552- targetSess.client = ac677+ expectedRole: attachmentActive, targetRole: attachmentActive,
553- targetSess.mu.Unlock()678+ expectedTransport: ac.transportSnapshot(), ready: true,
679+ })
680+ d.mu.Lock()
681+ if err != nil {
682+ return targetSess, attachmentTransitionResult{}, false, err
683+ }
554 d.touchMRU(targetSess)684 d.touchMRU(targetSess)
555 ac.recordPreviousSession(from)685 ac.recordPreviousSession(from)
556- return targetSess, cleanups, true, nil686+ return targetSess, transition, true, nil
557 }687 }
558 688
559 func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {689 func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
@@ -564,13 +694,13 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64)
564 // targets must use switchToTarget so resolution and commit share d.mu.694 // targets must use switchToTarget so resolution and commit share d.mu.
565 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {695 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
566 d.mu.Lock()696 d.mu.Lock()
567- _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{})697+ _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")
568 d.mu.Unlock()698 d.mu.Unlock()
569 if !switched {699 if !switched {
570 return nil700 return nil
571 }701 }
572- finishRenderLifecycleCleanups(cleanups)702+ d.deferAttachmentTransitionCleanups(transition)
573- return old703+ return transition.displaced.ac
574 }704 }
575 705
576 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves706 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
@@ -579,23 +709,45 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
579 d.mu.Lock()709 d.mu.Lock()
580 stopped, ok := d.stopped[target.Name]710 stopped, ok := d.stopped[target.Name]
581 var (711 var (
582- targetSess *session712+ transition attachmentTransitionResult
583- cleanups []renderLifecycleCleanup
584 switched bool713 switched bool
585 )714 )
586 if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {715 if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
587- targetSess, cleanups, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)716+ _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")
588 }717 }
589 d.mu.Unlock()718 d.mu.Unlock()
590 if !switched {719 if !switched {
591 d.invalidateRender(from, ac, true, "picker.go")720 d.invalidateRender(from, ac, true, "picker.go")
592 return false721 return false
593 }722 }
594- finishRenderLifecycleCleanups(cleanups)723+ d.deferAttachmentTransitionCleanups(transition)
595- d.firstPaint(targetSess, ac, ac.size)724+ d.firstPaintForTransition(transition.published)
596 return true725 return true
597 }726 }
598 727
728+func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentRoleToken) error {
729+ if token.ac == nil {
730+ return d.killPickerTarget(target)
731+ }
732+ if target.Stopped {
733+ token.effect.bindActionEnd(d, "picker-delete")
734+ token.effect.End()
735+ frozen := freezeRoleEffectGates(token.ac)
736+ defer frozen.unfreeze()
737+ if !d.sourceRoleTokenCurrentFrozen(token) {
738+ return nil
739+ }
740+ return d.killPickerTarget(target)
741+ }
742+ d.mu.Lock()
743+ targetSess := d.sessions[target.Session]
744+ d.mu.Unlock()
745+ if targetSess == nil {
746+ return nil
747+ }
748+ return d.killSessionForRole(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
749+}
750+
599 func (d *Daemon) killPickerTarget(target picker.Target) error {751 func (d *Daemon) killPickerTarget(target picker.Target) error {
600 if target.Stopped {752 if target.Stopped {
601 if err := d.retryStoppedPurge(target.Name); err != nil {753 if err := d.retryStoppedPurge(target.Name); err != nil {
M internal/usecase/daemon/picker_test.go +45 -16
@@ -505,7 +505,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
505 require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")505 require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")
506 }506 }
507 507
508-func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {508+func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) {
509 p1, releasePTY1 := newBlockingPTY(t)509 p1, releasePTY1 := newBlockingPTY(t)
510 p2, releasePTY2 := newBlockingPTY(t)510 p2, releasePTY2 := newBlockingPTY(t)
511 defer releasePTY1()511 defer releasePTY1()
@@ -540,11 +540,24 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
540 require.Same(t, ac1, sess2.client)540 require.Same(t, ac1, sess2.client)
541 require.Nil(t, sess1.client)541 require.Nil(t, sess1.client)
542 require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch")542 require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch")
543- det := awaitFrame(t, sends2, ports.MsgDetached)543+
544- dm, err := ports.UnmarshalDetached(det.Payload)544+ d.attachmentCleanupWg.Wait()
545+ var displacedFrames []ports.Frame
546+ for len(sends2) > 0 {
547+ displacedFrames = append(displacedFrames, <-sends2)
548+ }
549+ require.Len(t, displacedFrames, 1, "healthy displaced client receives only its snatched panel")
550+ require.Equal(t, ports.MsgOutput, displacedFrames[0].Type)
551+ displacedOutput, err := ports.UnmarshalOutput(displacedFrames[0].Payload)
545 require.NoError(t, err)552 require.NoError(t, err)
546- require.Equal(t, ports.ReasonReplaced, dm.Reason)553+ require.Contains(t, string(displacedOutput.Data), "Session snatched")
547- awaitFrame(t, sends1, ports.MsgOutput)554+ require.Same(t, sess2, ac2.currentSession())
555+ require.Equal(t, attachmentSnatched, sess2.attachmentRole(ac2))
556+
557+ movingFrame := awaitFrame(t, sends1, ports.MsgOutput)
558+ movingOutput, err := ports.UnmarshalOutput(movingFrame.Payload)
559+ require.NoError(t, err)
560+ require.Zero(t, movingOutput.BaseStateNum, "moving client must reset before painting the destination")
548 }561 }
549 562
550 func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {563 func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
@@ -623,7 +636,7 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
623 require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage")636 require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage")
624 }637 }
625 638
626-func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {639+func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing.T) {
627 p1, releasePTY1 := newBlockingPTY(t)640 p1, releasePTY1 := newBlockingPTY(t)
628 p2, releasePTY2 := newBlockingPTY(t)641 p2, releasePTY2 := newBlockingPTY(t)
629 defer releasePTY1()642 defer releasePTY1()
@@ -662,25 +675,41 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
662 }()675 }()
663 <-enteredSend676 <-enteredSend
664 677
665- switchDone := make(chan struct{})678+ switchDone := make(chan attachmentTransitionResult, 1)
679+ switchErr := make(chan error, 1)
666 go func() {680 go func() {
667- cleanups := d.handoffCoordinator(sess1, sess2, nil, ac)681+ result, err := d.transitionAttachment(attachmentTransitionRequest{
668- finishRenderLifecycleCleanups(cleanups)682+ source: sess1,
669- close(switchDone)683+ target: sess2,
684+ next: ac,
685+ expectedRole: attachmentActive,
686+ targetRole: attachmentActive,
687+ expectedTransport: ac.transportSnapshot(),
688+ ready: true,
689+ })
690+ switchDone <- result
691+ switchErr <- err
670 }()692 }()
671- awaitTestCompletion(t, handoffAtSendMu, "session switch did not reach the handoff sendMu boundary")
672 693
673- // The handoff is immediately about to acquire sendMu, which paint still owns694+ var result attachmentTransitionResult
674- // through its blocked transport send.695+ select {
696+ case result = <-switchDone:
697+ require.NoError(t, <-switchErr)
698+ case <-time.After(2 * time.Second):
699+ t.Fatal("session switch waited for in-flight transport send")
700+ }
701+ require.True(t, result.published.activeCurrent())
675 select {702 select {
676- case <-switchDone:703+ case <-handoffAtSendMu:
677- t.Fatal("session switch completed while paint owned sendMu")704+ t.Fatal("output rebase ran during architecture publication")
678 default:705 default:
679 }706 }
680 707
681 close(releaseSend)708 close(releaseSend)
682 awaitTestCompletion(t, paintDone, "paint did not finish after Send was released")709 awaitTestCompletion(t, paintDone, "paint did not finish after Send was released")
683- awaitTestCompletion(t, switchDone, "session handoff did not complete after paint finished")710+ for _, cleanup := range result.cleanups {
711+ cleanup.finish()
712+ }
684 }713 }
685 714
686 func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {715 func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
M internal/usecase/daemon/prompt.go +29 -3
@@ -23,10 +23,21 @@ func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial s
23 d.invalidateRender(sess, ac, true, "prompt.go")23 d.invalidateRender(sess, ac, true, "prompt.go")
24 }24 }
25 25
26+func (d *Daemon) enterTransitionPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string, attachmentRoleToken) error) {
27+ d.closePrompt(ac)
28+ ac.overlays.promptMu.Lock()
29+ ac.overlays.prompt = promptui.New(title, initial)
30+ ac.overlays.promptTransitionSubmit = submit
31+ ac.overlays.promptPending = nil
32+ ac.overlays.promptMu.Unlock()
33+ d.invalidateRender(sess, ac, true, "prompt.go")
34+}
35+
26 func (d *Daemon) closePrompt(ac *attachedClient) {36 func (d *Daemon) closePrompt(ac *attachedClient) {
27 ac.overlays.promptMu.Lock()37 ac.overlays.promptMu.Lock()
28 ac.overlays.prompt = nil38 ac.overlays.prompt = nil
29 ac.overlays.promptSubmit = nil39 ac.overlays.promptSubmit = nil
40+ ac.overlays.promptTransitionSubmit = nil
30 ac.overlays.promptPending = nil41 ac.overlays.promptPending = nil
31 ac.overlays.promptMu.Unlock()42 ac.overlays.promptMu.Unlock()
32 }43 }
@@ -39,7 +50,7 @@ func promptValidationError(err error) bool {
39 errors.Is(err, errSessionNameInUse)50 errors.Is(err, errSessionNameInUse)
40 }51 }
41 52
42-func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {53+func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
43 sess := ac.currentSession()54 sess := ac.currentSession()
44 if sess == nil {55 if sess == nil {
45 return56 return
@@ -55,6 +66,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
55 changed := false66 changed := false
56 exit := false67 exit := false
57 var submit func(string) error68 var submit func(string) error
69+ var transitionSubmit func(string, attachmentRoleToken) error
58 var submittedPrompt *promptui.Model70 var submittedPrompt *promptui.Model
59 var value string71 var value string
60 72
@@ -69,6 +81,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
69 },81 },
70 enter: func() {82 enter: func() {
71 submit = ac.overlays.promptSubmit83 submit = ac.overlays.promptSubmit
84+ transitionSubmit = ac.overlays.promptTransitionSubmit
72 submittedPrompt = ac.overlays.prompt85 submittedPrompt = ac.overlays.prompt
73 value = strings.TrimSpace(ac.overlays.prompt.Value())86 value = strings.TrimSpace(ac.overlays.prompt.Value())
74 },87 },
@@ -78,8 +91,21 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
78 })91 })
79 ac.overlays.promptMu.Unlock()92 ac.overlays.promptMu.Unlock()
80 93
81- if submit != nil {94+ if submit != nil || transitionSubmit != nil {
82- if err := submit(value); err != nil {95+ var err error
96+ if transitionSubmit != nil {
97+ var token attachmentRoleToken
98+ if len(effects) != 0 && effects[0] != nil {
99+ token = effects[0].roleToken()
100+ }
101+ err = transitionSubmit(value, token)
102+ } else {
103+ err = submit(value)
104+ }
105+ if errors.Is(err, errAttachmentTransition) {
106+ return
107+ }
108+ if err != nil {
83 ac.overlays.promptMu.Lock()109 ac.overlays.promptMu.Lock()
84 if ac.overlays.prompt == submittedPrompt {110 if ac.overlays.prompt == submittedPrompt {
85 ac.overlays.prompt.SetError(err.Error())111 ac.overlays.prompt.SetError(err.Error())
M internal/usecase/daemon/render.go +13 -2
@@ -210,8 +210,9 @@ func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid
210 // paint owns attachment sendMu while it captures, composes, and emits; JSONL210 // paint owns attachment sendMu while it captures, composes, and emits; JSONL
211 // observer I/O must therefore happen only after that ownership is released.211 // observer I/O must therefore happen only after that ownership is released.
212 type runtimeMarkBatch struct {212 type runtimeMarkBatch struct {
213- observer ports.RuntimeObserver213+ observer ports.RuntimeObserver
214- marks []ports.RuntimeMark214+ marks []ports.RuntimeMark
215+ roleEffect *roleEffectTicket
215 }216 }
216 217
217 func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {218 func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
@@ -243,6 +244,16 @@ func (b *runtimeMarkBatch) flush() {
243 244
244 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *attachmentLease) {245 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *attachmentLease) {
245 marks := d.newRuntimeMarkBatch()246 marks := d.newRuntimeMarkBatch()
247+ if lease != nil {
248+ token := sess.attachmentToken(ac, ac.transport())
249+ token.lease = lease
250+ ticket, admitted := ac.beginRoleEffect(token)
251+ if !admitted {
252+ return
253+ }
254+ marks.roleEffect = ticket
255+ defer ticket.End()
256+ }
246 tb := sess.activeTab()257 tb := sess.activeTab()
247 if tb == nil {258 if tb == nil {
248 return259 return
M internal/usecase/daemon/render_coordinator.go +60 -46
@@ -242,8 +242,17 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
242 // notifyAck reports that the client acknowledged an output state, releasing242 // notifyAck reports that the client acknowledged an output state, releasing
243 // at most one deadline/watchdog-deferred wake. It never bypasses an unexpired243 // at most one deadline/watchdog-deferred wake. It never bypasses an unexpired
244 // normal or urgent deadline.244 // normal or urgent deadline.
245-func (c *renderCoordinator) notifyAck() {245+func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) }
246+
247+// notifyAckForLease prevents an acknowledgement captured by an obsolete active
248+// frame from flushing the replacement attachment's render queue. A nil lease is
249+// reserved for coordinator-owned/direct test callers.
250+func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) {
246 c.mu.Lock()251 c.mu.Lock()
252+ if lease != nil && !c.leaseCurrentLocked(lease, true) {
253+ c.mu.Unlock()
254+ return
255+ }
247 if (!c.ackDeferred && !c.deadlineDue) || !c.pending {256 if (!c.ackDeferred && !c.deadlineDue) || !c.pending {
248 c.mu.Unlock()257 c.mu.Unlock()
249 return258 return
@@ -558,29 +567,41 @@ func (cleanup renderLifecycleCleanup) finish() {
558 // beginDetach invalidates pending work. It never waits for detached workers.567 // beginDetach invalidates pending work. It never waits for detached workers.
559 func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleCleanup {568 func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleCleanup {
560 c.mu.Lock()569 c.mu.Lock()
570+ cleanup := c.beginDetachLocked(ac)
571+ c.mu.Unlock()
572+ return cleanup
573+}
574+
575+// beginDetachLocked is the prelocked lifecycle seam used when registry,
576+// current-session, and lease publication must share one commit boundary.
577+func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycleCleanup {
561 var cleanup renderLifecycleCleanup578 var cleanup renderLifecycleCleanup
562 if c.lease != nil && c.lease.active && c.lease.attachment == ac {579 if c.lease != nil && c.lease.active && c.lease.attachment == ac {
563 c.lease.active = false580 c.lease.active = false
564- c.pending = false581+ cleanup = c.resetAttachmentLifecycleLocked()
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
582 }582 }
583- c.mu.Unlock()583+ return cleanup
584+}
585+
586+func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleCleanup {
587+ c.pending = false
588+ c.pendingReset = false
589+ c.pendingUrgent = false
590+ c.ackDeferred = false
591+ c.pendingPreview = false
592+ c.coalesced = 0
593+ _, timer := c.normalLane.replaceLocked()
594+ c.armed = false
595+ _, resizeTimer := c.resizeLane.replaceLocked()
596+ _, retryTimer := c.retryLane.replaceLocked()
597+ cleanup := renderLifecycleCleanup{
598+ tokens: []*timerToken{timer, resizeTimer, retryTimer},
599+ observer: c.opts.observer,
600+ queueCorrelation: c.queueCorrelation,
601+ queueMarked: c.queueMarked,
602+ ackBlocked: c.ackBlocked,
603+ }
604+ c.queueMarked, c.ackBlocked = false, nil
584 return cleanup605 return cleanup
585 }606 }
586 607
@@ -593,36 +614,29 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
593 // for detached workers.614 // for detached workers.
594 func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup {615 func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup {
595 c.mu.Lock()616 c.mu.Lock()
596- var cleanup renderLifecycleCleanup617+ cleanup, _ := c.beginReplaceLocked(old, replacement, ready)
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- }
622 c.mu.Unlock()618 c.mu.Unlock()
623 return cleanup619 return cleanup
624 }620 }
625 621
622+// beginReplaceLocked requires c.mu. Callers validate canReplaceLocked before
623+// their ownership commit, making lease installation non-failing afterward.
624+func (c *renderCoordinator) beginReplaceLocked(old, replacement *attachedClient, ready bool) (renderLifecycleCleanup, *attachmentLease) {
625+ if !c.canReplaceLocked(old, replacement) {
626+ return renderLifecycleCleanup{}, nil
627+ }
628+ lease := c.installLeaseLocked(replacement, ready)
629+ return c.resetAttachmentLifecycleLocked(), lease
630+}
631+
632+func (c *renderCoordinator) canReplaceLocked(old, replacement *attachedClient) bool {
633+ // A coordinator may be installed while replacing a legacy attachment which
634+ // predated coordinator ownership. Any inactive lease is detached historical
635+ // state; a resume may also replace the same client object because registry and
636+ // transport validation proved a new role incarnation.
637+ return !c.torndown && (c.lease == nil || !c.lease.active || c.lease.attachment == old || c.lease.attachment == replacement)
638+}
639+
626 // noteReplace is for callers that hold no outer lifecycle locks.640 // noteReplace is for callers that hold no outer lifecycle locks.
627 func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) {641 func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) {
628 ready := true642 ready := true
M internal/usecase/daemon/render_pipeline.go +26 -12
@@ -415,17 +415,10 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
415 return false415 return false
416 }416 }
417 }417 }
418- ac.sess.mu.Lock()
419- if ac.sess.v != sess {
420- ac.sess.mu.Unlock()
421- ac.sendMu.Unlock()
422- return false
423- }
424 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)418 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
425 prepared, err := ac.output.prepare(composed.frame, composed.damage, composed.reset)419 prepared, err := ac.output.prepare(composed.frame, composed.damage, composed.reset)
426 endDiff(0, err == nil)420 endDiff(0, err == nil)
427 if err != nil {421 if err != nil {
428- ac.sess.mu.Unlock()
429 ac.sendMu.Unlock()422 ac.sendMu.Unlock()
430 d.log.Error("render draw failed", "err", err, "session", sess.name)423 d.log.Error("render draw failed", "err", err, "session", sess.name)
431 // Without a coordinator reportError repaints synchronously. Suppress only424 // Without a coordinator reportError repaints synchronously. Suppress only
@@ -448,7 +441,8 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
448 var sendTr ports.Transport441 var sendTr ports.Transport
449 var sendErr error442 var sendErr error
450 if len(data) > 0 {443 if len(data) > 0 {
451- sendTr = ac.transport()444+ sendTransport := ac.transportSnapshot()
445+ sendTr = sendTransport.transport
452 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))446 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
453 if sendTr == nil {447 if sendTr == nil {
454 sendErr = errors.New("client transport is nil")448 sendErr = errors.New("client transport is nil")
@@ -457,18 +451,25 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
457 if async, ok := sendTr.(ports.AsyncTransport); ok {451 if async, ok := sendTr.(ports.AsyncTransport); ok {
458 send = async.SendAsync452 send = async.SendAsync
459 }453 }
454+ interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport)
460 sendErr = prepared.send(data, ac.echoAck.Load(), send)455 sendErr = prepared.send(data, ac.echoAck.Load(), send)
456+ if interruptible {
457+ if sendErr != nil {
458+ marks.roleEffect.reportTransportFailure(sendTransport)
459+ }
460+ marks.roleEffect.endTransportSend()
461+ }
461 }462 }
462 endEmit(uint64(len(data)), sendErr == nil)463 endEmit(uint64(len(data)), sendErr == nil)
463 }464 }
464 if sendErr == nil {465 if sendErr == nil {
465 // Publish only after output preparation and transport emission both466 // Publish only after output preparation and transport emission both
466- // succeed. Receipt acknowledgement follows after releasing the attachment467+ // succeed. A cross-session transition may publish concurrently, but its
467- // session guard, while sendMu still owns this transaction.468+ // mandatory first-paint rebase waits for sendMu and therefore follows this
469+ // completed output transaction.
468 ac.pipelineScratch = ac.pipelineCache470 ac.pipelineScratch = ac.pipelineCache
469 ac.pipelineCache = composed.cache471 ac.pipelineCache = composed.cache
470 }472 }
471- ac.sess.mu.Unlock()
472 if sendErr == nil {473 if sendErr == nil {
473 // A successful no-byte emission also commits: its renderer shadow still474 // A successful no-byte emission also commits: its renderer shadow still
474 // represents the captured frame. Lock panes only under sendMu and with no475 // represents the captured frame. Lock panes only under sendMu and with no
@@ -480,7 +481,20 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
480 }481 }
481 ac.sendMu.Unlock()482 ac.sendMu.Unlock()
482 if sendErr != nil {483 if sendErr != nil {
483- d.detachOnSendError(sess, ac, sendTr)484+ // A transport failure may invalidate the role gate. Release this render's
485+ // admission first. Detachment freezes the gate and therefore cannot mutate
486+ // ownership until any enclosing admitted operation has also ended.
487+ if marks.roleEffect == nil {
488+ d.detachOnSendError(sess, ac, sendTr)
489+ } else {
490+ // Capture the exact admitted capability, including its coordinator
491+ // lease, before End permits a replacement publication. Reserve cleanup
492+ // accounting before End so terminal Wait cannot race a later Add.
493+ token := marks.roleEffect.roleToken()
494+ launchCleanup := d.reserveRoleSendErrorCleanup(token, sendTr)
495+ marks.roleEffect.End()
496+ launchCleanup()
497+ }
484 }498 }
485 return true499 return true
486 }500 }
M internal/usecase/daemon/resume.go +19 -3
@@ -183,15 +183,31 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
183 ac.size = sz183 ac.size = sz
184 ac.resumeToken = d.nextResumeTokenLocked()184 ac.resumeToken = d.nextResumeTokenLocked()
185 ac.parked = false185 ac.parked = false
186- ac.setSession(sess)
187 // The resumed session's snapshot is the sole source for future PTY children.186 // The resumed session's snapshot is the sole source for future PTY children.
188 // Existing PTYs retain the environment they were started with.187 // Existing PTYs retain the environment they were started with.
189 sess.mu.Lock()188 sess.mu.Lock()
190 sess.env = copyEnvironment(h.Env)189 sess.env = copyEnvironment(h.Env)
191 sess.terminal = terminalEnv{TrueColor: h.TrueColor}190 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
192- sess.client = ac
193 sess.mu.Unlock()191 sess.mu.Unlock()
194- d.attachCoordinator(sess, nil, ac, false)192+ // Resume preparation used sendMu -> d.mu, but freeze/drain must run with
193+ // neither held. Reacquire them before returning to preserve this helper's
194+ // locked-caller contract.
195+ d.mu.Unlock()
196+ ac.sendMu.Unlock()
197+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
198+ target: sess,
199+ next: ac,
200+ expectedRole: attachmentDetached,
201+ targetRole: attachmentActive,
202+ expectedTransport: ac.transportSnapshot(),
203+ ready: false,
204+ })
205+ ac.sendMu.Lock()
206+ d.mu.Lock()
207+ if err != nil {
208+ return nil, nil, false, &protoErr{ports.ErrInternal, "resume attachment transition failed"}
209+ }
210+ d.deferAttachmentTransitionCleanups(transition)
195 d.touchMRU(sess)211 d.touchMRU(sess)
196 d.log.Info("client resumed", "session", sess.name)212 d.log.Info("client resumed", "session", sess.name)
197 return sess, ac, true, nil213 return sess, ac, true, nil
M internal/usecase/daemon/resume_test.go +2 -0
@@ -308,6 +308,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
308 token := ac.resumeToken308 token := ac.resumeToken
309 require.True(t, sess.detachIfCurrent(ac))309 require.True(t, sess.detachIfCurrent(ac))
310 require.True(t, d.parkAttachment(sess, ac))310 require.True(t, d.parkAttachment(sess, ac))
311+ generation := ac.roleGeneration.Load()
311 312
312 newTr := &closeTrackingTransport{}313 newTr := &closeTrackingTransport{}
313 resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24})314 resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24})
@@ -315,6 +316,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
315 require.True(t, ok)316 require.True(t, ok)
316 require.Same(t, sess, resumedSess)317 require.Same(t, sess, resumedSess)
317 require.Same(t, ac, resumedAC)318 require.Same(t, ac, resumedAC)
319+ require.Greater(t, ac.roleGeneration.Load(), generation, "resume must publish active ownership through the attachment transition")
318 320
319 _ = ac.closeCapturedTransport(oldTr)321 _ = ac.closeCapturedTransport(oldTr)
320 require.True(t, oldTr.Closed(), "old transport is closed")322 require.True(t, oldTr.Closed(), "old transport is closed")
A internal/usecase/daemon/role_effect_gate.go +492 -0
@@ -0,0 +1,492 @@
1+package daemon
2+
3+import (
4+ "sort"
5+ "sync"
6+ "sync/atomic"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+type roleEffectPhase uint8
12+
13+const (
14+ roleEffectsStable roleEffectPhase = iota
15+ roleEffectsFrozen
16+)
17+
18+// roleCapability is the single attachment-local publication consumed by role
19+// effect admission. It deliberately contains no independently mutable state:
20+// a ticket is admitted only when every field matches the frame's token.
21+type roleCapability struct {
22+ sess *session
23+ role attachmentRole
24+ generation uint64
25+ transport transportSnapshot
26+ lease *attachmentLease
27+}
28+
29+func capabilityFromToken(token attachmentRoleToken) roleCapability {
30+ return roleCapability{
31+ sess: token.sess,
32+ role: token.role,
33+ generation: token.generation,
34+ transport: token.transport,
35+ lease: token.lease,
36+ }
37+}
38+
39+func (c roleCapability) matches(token attachmentRoleToken) bool {
40+ return token.ac != nil && c.sess == token.sess && c.role == token.role &&
41+ c.generation == token.generation && c.transport.transport == token.transport.transport &&
42+ c.transport.incarnation == token.transport.incarnation && c.lease == token.lease
43+}
44+
45+type roleEffectGate struct {
46+ mu sync.Mutex
47+ cond *sync.Cond
48+ changed chan struct{}
49+ phase roleEffectPhase
50+ capability roleCapability
51+ inFlight uint64
52+ transportEffects map[*roleEffectTicket]transportSnapshot
53+ failedTransport transportSnapshot
54+ order atomic.Uint64
55+}
56+
57+var nextRoleEffectGateOrder atomic.Uint64
58+
59+type roleEffectDrainDeadline struct {
60+ clock ports.Clock
61+ done chan struct{}
62+ stopCh chan struct{}
63+ finished chan struct{}
64+ startOnce sync.Once
65+ stopOnce sync.Once
66+ started atomic.Bool
67+}
68+
69+func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline {
70+ return &roleEffectDrainDeadline{
71+ clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}),
72+ }
73+}
74+
75+func (d *roleEffectDrainDeadline) stop() {
76+ if d == nil {
77+ return
78+ }
79+ d.stopOnce.Do(func() {
80+ // Claim an unstarted deadline without allocating a timer. Done is never
81+ // called after the owning freeze/drain operation returns.
82+ d.startOnce.Do(func() {})
83+ if !d.started.Load() {
84+ return
85+ }
86+ close(d.stopCh)
87+ <-d.finished
88+ })
89+}
90+
91+func (d *roleEffectDrainDeadline) Done() <-chan struct{} {
92+ if d == nil {
93+ return nil
94+ }
95+ d.startOnce.Do(func() {
96+ d.started.Store(true)
97+ timer := d.clock.NewTimer(detachNotifyTimeout)
98+ go func() {
99+ defer close(d.finished)
100+ select {
101+ case <-timer.C():
102+ close(d.done)
103+ case <-d.stopCh:
104+ timer.Stop()
105+ }
106+ }()
107+ })
108+ return d.done
109+}
110+
111+func (g *roleEffectGate) initLocked() {
112+ if g.cond == nil {
113+ g.cond = sync.NewCond(&g.mu)
114+ }
115+ if g.changed == nil {
116+ g.changed = make(chan struct{})
117+ }
118+}
119+
120+func (g *roleEffectGate) signalLocked() {
121+ g.initLocked()
122+ close(g.changed)
123+ g.changed = make(chan struct{})
124+ g.cond.Broadcast()
125+}
126+
127+func (g *roleEffectGate) immutableOrder() uint64 {
128+ if order := g.order.Load(); order != 0 {
129+ return order
130+ }
131+ order := nextRoleEffectGateOrder.Add(1)
132+ if g.order.CompareAndSwap(0, order) {
133+ return order
134+ }
135+ return g.order.Load()
136+}
137+
138+// roleEffectTicket reserves the exact published capability until End. End is
139+// idempotent so error paths can safely defer it before doing any observable I/O.
140+type roleEffectTicket struct {
141+ gate *roleEffectGate
142+ token attachmentRoleToken
143+ actionDaemon *Daemon
144+ action string
145+ ended atomic.Bool
146+}
147+
148+func (t *roleEffectTicket) bindActionEnd(d *Daemon, action string) {
149+ if t == nil || t.ended.Load() {
150+ return
151+ }
152+ t.actionDaemon = d
153+ t.action = action
154+}
155+
156+func (t *roleEffectTicket) roleToken() attachmentRoleToken {
157+ if t == nil {
158+ return attachmentRoleToken{}
159+ }
160+ token := t.token
161+ token.effect = t
162+ return token
163+}
164+
165+func (t *roleEffectTicket) End() {
166+ if t == nil || t.gate == nil || !t.ended.CompareAndSwap(false, true) {
167+ return
168+ }
169+ g := t.gate
170+ g.mu.Lock()
171+ g.initLocked()
172+ if g.inFlight == 0 {
173+ g.mu.Unlock()
174+ panic("daemon: role effect ticket ended without admission")
175+ }
176+ delete(g.transportEffects, t)
177+ g.inFlight--
178+ g.signalLocked()
179+ g.mu.Unlock()
180+ if t.actionDaemon != nil && t.actionDaemon.afterActionRoleEffectEnded != nil {
181+ t.actionDaemon.afterActionRoleEffectEnded(t.action)
182+ }
183+}
184+
185+func (d *Daemon) endActionRoleEffect(effect *roleEffectTicket, _ string) {
186+ if effect != nil {
187+ effect.End()
188+ }
189+}
190+
191+// beginTransportSend marks only the interval spent in an external transport
192+// send as interruptible. A replacement may close this exact published link to
193+// release the send, but cannot retire an idle healthy snatched connection.
194+func (t *roleEffectTicket) beginTransportSend(expected transportSnapshot) bool {
195+ if t == nil || t.gate == nil || t.ended.Load() || expected.transport == nil {
196+ return false
197+ }
198+ g := t.gate
199+ g.mu.Lock()
200+ g.initLocked()
201+ if t.ended.Load() || g.capability.transport.transport != expected.transport ||
202+ g.capability.transport.incarnation != expected.incarnation {
203+ g.mu.Unlock()
204+ return false
205+ }
206+ if g.transportEffects == nil {
207+ g.transportEffects = make(map[*roleEffectTicket]transportSnapshot)
208+ }
209+ g.transportEffects[t] = expected
210+ g.signalLocked()
211+ g.mu.Unlock()
212+ return true
213+}
214+
215+func (t *roleEffectTicket) reportTransportFailure(expected transportSnapshot) {
216+ if t == nil || t.gate == nil || expected.transport == nil {
217+ return
218+ }
219+ g := t.gate
220+ g.mu.Lock()
221+ if !t.ended.Load() && g.capability.transport.transport == expected.transport &&
222+ g.capability.transport.incarnation == expected.incarnation {
223+ g.failedTransport = expected
224+ g.signalLocked()
225+ }
226+ g.mu.Unlock()
227+}
228+
229+func (t *roleEffectTicket) endTransportSend() {
230+ if t == nil || t.gate == nil {
231+ return
232+ }
233+ g := t.gate
234+ g.mu.Lock()
235+ if _, exists := g.transportEffects[t]; exists {
236+ delete(g.transportEffects, t)
237+ g.signalLocked()
238+ }
239+ g.mu.Unlock()
240+}
241+
242+// beginRoleEffect is the sole role-bound effect admission point. The gate is
243+// held only long enough to reserve the capability; the effect itself must run
244+// without the gate mutex.
245+func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffectTicket, bool) {
246+ if ac == nil || token.ac != ac {
247+ return nil, false
248+ }
249+ g := &ac.roleEffects
250+ g.mu.Lock()
251+ g.initLocked()
252+ if g.phase != roleEffectsStable || !g.capability.matches(token) {
253+ g.mu.Unlock()
254+ return nil, false
255+ }
256+ g.inFlight++
257+ g.mu.Unlock()
258+ captured := token
259+ captured.effect = nil
260+ return &roleEffectTicket{gate: g, token: captured}, true
261+}
262+
263+// publishRoleCapability is used by direct/headless setup and by transition
264+// publication while the gate is frozen. Production role changes publish only
265+// through publishFrozenRoleCapability.
266+func (ac *attachedClient) publishRoleCapability(token attachmentRoleToken) {
267+ if ac == nil {
268+ return
269+ }
270+ g := &ac.roleEffects
271+ g.mu.Lock()
272+ g.initLocked()
273+ g.capability = capabilityFromToken(token)
274+ g.failedTransport = transportSnapshot{}
275+ g.mu.Unlock()
276+}
277+
278+// bootstrapRoleCapability supports direct/headless session construction. It
279+// never changes an existing or frozen production publication.
280+func (ac *attachedClient) bootstrapRoleCapability(token attachmentRoleToken) {
281+ if ac == nil || token.sess == nil || token.role == attachmentDetached {
282+ return
283+ }
284+ g := &ac.roleEffects
285+ g.mu.Lock()
286+ g.initLocked()
287+ if g.phase == roleEffectsStable && g.capability.sess == nil {
288+ g.capability = capabilityFromToken(token)
289+ g.failedTransport = transportSnapshot{}
290+ }
291+ g.mu.Unlock()
292+}
293+
294+func (ac *attachedClient) publishFrozenRoleCapability(token attachmentRoleToken) {
295+ g := &ac.roleEffects
296+ g.mu.Lock()
297+ g.initLocked()
298+ if g.phase != roleEffectsFrozen {
299+ g.mu.Unlock()
300+ panic("daemon: publishing role capability without transition freeze")
301+ }
302+ g.capability = capabilityFromToken(token)
303+ g.failedTransport = transportSnapshot{}
304+ g.mu.Unlock()
305+}
306+
307+func (ac *attachedClient) invalidateFrozenRoleCapability() {
308+ g := &ac.roleEffects
309+ g.mu.Lock()
310+ g.initLocked()
311+ if g.phase != roleEffectsFrozen {
312+ g.mu.Unlock()
313+ panic("daemon: invalidating role capability without transition freeze")
314+ }
315+ g.capability = roleCapability{}
316+ g.failedTransport = transportSnapshot{}
317+ g.mu.Unlock()
318+}
319+
320+type frozenRoleEffectGates struct {
321+ clients []*attachedClient
322+ interruptions map[*attachedClient]transportSnapshot
323+ acquired bool
324+ drained bool
325+}
326+
327+func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool {
328+ interrupted, ok := f.interruptions[ac]
329+ return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation
330+}
331+
332+type roleTransportInterrupt struct {
333+ ac *attachedClient
334+ transport transportSnapshot
335+}
336+
337+// freezeRoleEffectGates establishes transition priority in immutable attachment
338+// order, then drains earlier admissions. It must be called without daemon,
339+// routing, session, coordinator, send, overlay, router, or pane locks held.
340+func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates {
341+ return freezeRoleEffectGatesInterrupting(nil, clients...)
342+}
343+
344+// freezeRoleEffectGatesInterrupting may close an exact captured transport only
345+// while an earlier admitted effect is inside that transport's Send. Closing and
346+// all drain waits happen without the gate mutex or an architecture lock held.
347+func freezeRoleEffectGatesInterrupting(interrupts []roleTransportInterrupt, clients ...*attachedClient) frozenRoleEffectGates {
348+ return freezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...)
349+}
350+
351+func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterrupt, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
352+ return freezeRoleEffectGatesInterruptingObservedUntil(interrupts, nil, afterFrozen, clients...)
353+}
354+
355+// freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound
356+// for ordered acquisition and drain. Acquisition is all-or-nothing: if the
357+// bound expires behind another owner, only gates acquired by this call are
358+// rolled back, in reverse order. Once the complete set is owned, a terminal
359+// teardown may proceed when done closes during drain; late tickets then retire
360+// against an invalidated capability without pinning the session owner
361+// indefinitely.
362+func freezeRoleEffectGatesInterruptingObservedUntil(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates {
363+ unique := make(map[*attachedClient]struct{}, len(clients))
364+ ordered := make([]*attachedClient, 0, len(clients))
365+ for _, ac := range clients {
366+ if ac == nil {
367+ continue
368+ }
369+ if _, exists := unique[ac]; exists {
370+ continue
371+ }
372+ unique[ac] = struct{}{}
373+ ac.roleEffects.immutableOrder()
374+ ordered = append(ordered, ac)
375+ }
376+ sort.Slice(ordered, func(i, j int) bool {
377+ return ordered[i].roleEffects.order.Load() < ordered[j].roleEffects.order.Load()
378+ })
379+
380+ frozenClients := make([]*attachedClient, 0, len(ordered))
381+ for _, ac := range ordered {
382+ g := &ac.roleEffects
383+ g.mu.Lock()
384+ g.initLocked()
385+ for g.phase == roleEffectsFrozen {
386+ if done == nil {
387+ g.cond.Wait()
388+ continue
389+ }
390+ changed := g.changed
391+ doneCh := done()
392+ g.mu.Unlock()
393+ select {
394+ case <-changed:
395+ case <-doneCh:
396+ }
397+ g.mu.Lock()
398+ select {
399+ case <-doneCh:
400+ g.mu.Unlock()
401+ partial := frozenRoleEffectGates{clients: frozenClients}
402+ partial.unfreeze()
403+ return frozenRoleEffectGates{}
404+ default:
405+ }
406+ }
407+ g.phase = roleEffectsFrozen
408+ g.mu.Unlock()
409+ frozenClients = append(frozenClients, ac)
410+ if afterFrozen != nil {
411+ afterFrozen(ac)
412+ }
413+ }
414+ interruptByClient := make(map[*attachedClient]transportSnapshot, len(interrupts))
415+ for _, interrupt := range interrupts {
416+ if interrupt.ac != nil && interrupt.transport.transport != nil {
417+ interruptByClient[interrupt.ac] = interrupt.transport
418+ }
419+ }
420+ // Snapshot every exact in-flight link while all participant gates are frozen,
421+ // then close the complete set before waiting on any one ticket. One
422+ // uncooperative Send therefore cannot prevent another participant's Send
423+ // from being interrupted.
424+ interruptions := make(map[*attachedClient]transportSnapshot, len(interruptByClient))
425+ toClose := make([]roleTransportInterrupt, 0, len(interruptByClient))
426+ for _, ac := range ordered {
427+ expected, mayInterrupt := interruptByClient[ac]
428+ if !mayInterrupt {
429+ continue
430+ }
431+ g := &ac.roleEffects
432+ g.mu.Lock()
433+ if gateHasTransportEffectLocked(g, expected) {
434+ toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected})
435+ }
436+ g.mu.Unlock()
437+ }
438+ for _, interrupt := range toClose {
439+ _ = interrupt.ac.closeCapturedTransport(interrupt.transport.transport)
440+ }
441+
442+ for _, ac := range ordered {
443+ g := &ac.roleEffects
444+ g.mu.Lock()
445+ for g.inFlight != 0 {
446+ if done == nil {
447+ g.cond.Wait()
448+ continue
449+ }
450+ changed := g.changed
451+ doneCh := done()
452+ g.mu.Unlock()
453+ select {
454+ case <-changed:
455+ case <-doneCh:
456+ }
457+ g.mu.Lock()
458+ select {
459+ case <-doneCh:
460+ g.mu.Unlock()
461+ return frozenRoleEffectGates{clients: frozenClients, interruptions: interruptions, acquired: true}
462+ default:
463+ }
464+ }
465+ if expected, mayInterrupt := interruptByClient[ac]; mayInterrupt &&
466+ g.failedTransport.transport == expected.transport && g.failedTransport.incarnation == expected.incarnation {
467+ interruptions[ac] = expected
468+ }
469+ g.mu.Unlock()
470+ }
471+ return frozenRoleEffectGates{clients: frozenClients, interruptions: interruptions, acquired: true, drained: true}
472+}
473+
474+func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool {
475+ for _, active := range g.transportEffects {
476+ if active.transport == expected.transport && active.incarnation == expected.incarnation {
477+ return true
478+ }
479+ }
480+ return false
481+}
482+
483+func (f frozenRoleEffectGates) unfreeze() {
484+ for i := len(f.clients) - 1; i >= 0; i-- {
485+ g := &f.clients[i].roleEffects
486+ g.mu.Lock()
487+ g.initLocked()
488+ g.phase = roleEffectsStable
489+ g.signalLocked()
490+ g.mu.Unlock()
491+ }
492+}
A internal/usecase/daemon/role_effect_gate_test.go +1448 -0
@@ -0,0 +1,1448 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/keys"
15+ "github.com/bnema/vev/internal/usecase/picker"
16+ "github.com/bnema/vev/pkg/renderer"
17+)
18+
19+type blockedRenderReplacementTransport struct {
20+ sendEntered chan struct{}
21+ closed chan struct{}
22+ sendOnce sync.Once
23+ closeOnce sync.Once
24+ locksFree chan bool
25+ d *Daemon
26+ sess *session
27+}
28+
29+func newBlockedRenderReplacementTransport(d *Daemon, sess *session) *blockedRenderReplacementTransport {
30+ return &blockedRenderReplacementTransport{
31+ sendEntered: make(chan struct{}),
32+ closed: make(chan struct{}),
33+ locksFree: make(chan bool, 1),
34+ d: d,
35+ sess: sess,
36+ }
37+}
38+
39+func (t *blockedRenderReplacementTransport) Send(ports.Frame) error {
40+ t.sendOnce.Do(func() { close(t.sendEntered) })
41+ <-t.closed
42+ return errors.New("transport closed")
43+}
44+
45+func (*blockedRenderReplacementTransport) Recv() (ports.Frame, error) {
46+ return ports.Frame{}, io.EOF
47+}
48+
49+func (t *blockedRenderReplacementTransport) Close() error {
50+ locksFree := t.d.mu.TryLock()
51+ if locksFree {
52+ t.d.mu.Unlock()
53+ }
54+ if locksFree {
55+ locksFree = t.d.notices.routingMu.TryLock()
56+ if locksFree {
57+ t.d.notices.routingMu.Unlock()
58+ }
59+ }
60+ if locksFree {
61+ locksFree = t.sess.mu.TryLock()
62+ if locksFree {
63+ t.sess.mu.Unlock()
64+ }
65+ }
66+ if locksFree {
67+ if rc := t.sess.renderCoordinator(); rc != nil {
68+ locksFree = rc.mu.TryLock()
69+ if locksFree {
70+ rc.mu.Unlock()
71+ }
72+ }
73+ }
74+ t.closeOnce.Do(func() {
75+ t.locksFree <- locksFree
76+ close(t.closed)
77+ })
78+ return nil
79+}
80+
81+type teardownBlockingTransport struct {
82+ sendEntered chan struct{}
83+ closed chan struct{}
84+ sendOnce sync.Once
85+ closeOnce sync.Once
86+}
87+
88+func newTeardownBlockingTransport() *teardownBlockingTransport {
89+ return &teardownBlockingTransport{sendEntered: make(chan struct{}), closed: make(chan struct{})}
90+}
91+
92+func (t *teardownBlockingTransport) Send(ports.Frame) error {
93+ t.sendOnce.Do(func() { close(t.sendEntered) })
94+ <-t.closed
95+ return errors.New("transport closed")
96+}
97+
98+func (*teardownBlockingTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
99+
100+func (t *teardownBlockingTransport) Close() error {
101+ t.closeOnce.Do(func() { close(t.closed) })
102+ return nil
103+}
104+
105+func (t *teardownBlockingTransport) Closed() bool {
106+ select {
107+ case <-t.closed:
108+ return true
109+ default:
110+ return false
111+ }
112+}
113+
114+func startBlockedRoleSend(t *testing.T, token attachmentRoleToken) <-chan struct{} {
115+ t.Helper()
116+ ticket, admitted := token.ac.beginRoleEffect(token)
117+ require.True(t, admitted)
118+ done := make(chan struct{})
119+ go func() {
120+ defer close(done)
121+ defer ticket.End()
122+ _ = token.ac.sendExpectedTransportForRole(token.transport, framePong(), ticket)
123+ }()
124+ return done
125+}
126+
127+func requireRoleGateRetired(t *testing.T, ac *attachedClient) {
128+ t.Helper()
129+ ac.roleEffects.mu.Lock()
130+ defer ac.roleEffects.mu.Unlock()
131+ require.Equal(t, roleEffectsStable, ac.roleEffects.phase)
132+ require.Zero(t, ac.roleEffects.inFlight)
133+ require.Empty(t, ac.roleEffects.transportEffects)
134+}
135+
136+func roleGatePublicationSnapshot(ac *attachedClient) (roleEffectPhase, roleCapability) {
137+ ac.roleEffects.mu.Lock()
138+ defer ac.roleEffects.mu.Unlock()
139+ return ac.roleEffects.phase, ac.roleEffects.capability
140+}
141+
142+func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testing.T) {
143+ d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
144+ firstTransport := &closeTrackingTransport{}
145+ first.replaceTransport(firstTransport)
146+
147+ blockedTransport := &closeTrackingTransport{}
148+ blocked := &attachedClient{tr: blockedTransport, output: newOutputStateStream(), size: first.size}
149+ blocked.initOverlays()
150+ blocked.setSession(sess)
151+ laterTransport := &closeTrackingTransport{}
152+ later := &attachedClient{tr: laterTransport, output: newOutputStateStream(), size: first.size}
153+ later.initOverlays()
154+ later.setSession(sess)
155+ sess.mu.Lock()
156+ sess.addSnatchedLocked(blocked)
157+ sess.addSnatchedLocked(later)
158+ sess.mu.Unlock()
159+
160+ rc := d.attachCoordinator(sess, nil, first, true)
161+ firstToken := sess.attachmentToken(first, firstTransport)
162+ firstToken.lease = rc.attachmentLease(first)
163+ first.publishRoleCapability(firstToken)
164+ blockedToken := sess.attachmentToken(blocked, blockedTransport)
165+ blocked.publishRoleCapability(blockedToken)
166+ laterToken := sess.attachmentToken(later, laterTransport)
167+ later.publishRoleCapability(laterToken)
168+
169+ // Fix the canonical order so teardown acquires first, waits behind the gate
170+ // owned by this test, and never reaches the still-stable later participant.
171+ first.roleEffects.immutableOrder()
172+ blocked.roleEffects.immutableOrder()
173+ later.roleEffects.immutableOrder()
174+ require.Less(t, first.roleEffects.order.Load(), blocked.roleEffects.order.Load())
175+ require.Less(t, blocked.roleEffects.order.Load(), later.roleEffects.order.Load())
176+
177+ blockedOwner := freezeRoleEffectGates(blocked)
178+ blockedOwnerHeld := true
179+ t.Cleanup(func() {
180+ if blockedOwnerHeld {
181+ blockedOwner.unfreeze()
182+ }
183+ })
184+
185+ firstPhase, firstCapability := roleGatePublicationSnapshot(first)
186+ blockedPhase, blockedCapability := roleGatePublicationSnapshot(blocked)
187+ laterPhase, laterCapability := roleGatePublicationSnapshot(later)
188+ require.Equal(t, roleEffectsStable, firstPhase)
189+ require.Equal(t, roleEffectsFrozen, blockedPhase)
190+ require.Equal(t, roleEffectsStable, laterPhase)
191+ firstGeneration := first.roleGeneration.Load()
192+ blockedGeneration := blocked.roleGeneration.Load()
193+ laterGeneration := later.roleGeneration.Load()
194+ firstSnapshot := first.transportSnapshot()
195+ blockedSnapshot := blocked.transportSnapshot()
196+ laterSnapshot := later.transportSnapshot()
197+
198+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
199+ d.clock = clock
200+ frozenByKill := make(chan *attachedClient, 3)
201+ d.afterRoleEffectGateFrozen = func(_ string, ac *attachedClient) { frozenByKill <- ac }
202+ killDone := make(chan error, 1)
203+ go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
204+
205+ require.Same(t, first, awaitTestValue(t, frozenByKill, "teardown did not acquire the first role gate"))
206+ deadline := awaitTestValue(t, clock.timers, "teardown did not arm the gate acquisition deadline")
207+ require.Equal(t, detachNotifyTimeout, deadline.duration)
208+ deadline.ch <- time.Now()
209+ select {
210+ case err := <-killDone:
211+ require.NoError(t, err)
212+ case <-time.After(time.Second):
213+ t.Fatal("teardown did not abort after partial gate acquisition timed out")
214+ }
215+ require.Empty(t, frozenByKill, "teardown acquired a participant after its deadline expired")
216+
217+ d.mu.Lock()
218+ require.Same(t, sess, d.sessions[sess.id], "partial gate acquisition removed the session registry owner")
219+ d.mu.Unlock()
220+ sess.mu.Lock()
221+ require.Same(t, first, sess.client)
222+ require.Contains(t, sess.snatched, blocked)
223+ require.Contains(t, sess.snatched, later)
224+ sess.mu.Unlock()
225+ for _, ac := range []*attachedClient{first, blocked, later} {
226+ require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership")
227+ }
228+ require.Equal(t, firstGeneration, first.roleGeneration.Load())
229+ require.Equal(t, blockedGeneration, blocked.roleGeneration.Load())
230+ require.Equal(t, laterGeneration, later.roleGeneration.Load())
231+ require.True(t, first.transportSnapshotCurrent(firstSnapshot))
232+ require.True(t, blocked.transportSnapshotCurrent(blockedSnapshot))
233+ require.True(t, later.transportSnapshotCurrent(laterSnapshot))
234+ require.False(t, firstTransport.Closed())
235+ require.False(t, blockedTransport.Closed())
236+ require.False(t, laterTransport.Closed())
237+
238+ firstPhase, gotFirstCapability := roleGatePublicationSnapshot(first)
239+ blockedPhase, gotBlockedCapability := roleGatePublicationSnapshot(blocked)
240+ laterPhase, gotLaterCapability := roleGatePublicationSnapshot(later)
241+ require.Equal(t, roleEffectsStable, firstPhase, "partially acquired gate was not rolled back")
242+ require.Equal(t, roleEffectsFrozen, blockedPhase, "teardown released a gate owned by another transition")
243+ require.Equal(t, roleEffectsStable, laterPhase, "unacquired later gate was frozen or invalidated")
244+ require.Equal(t, firstCapability, gotFirstCapability)
245+ require.Equal(t, blockedCapability, gotBlockedCapability)
246+ require.Equal(t, laterCapability, gotLaterCapability)
247+
248+ blockedOwner.unfreeze()
249+ blockedOwnerHeld = false
250+ complete := freezeRoleEffectGates(first, blocked, later)
251+ require.True(t, complete.acquired, "rolled-back gate set could not be acquired again")
252+ require.True(t, complete.drained)
253+ complete.unfreeze()
254+ for _, token := range []attachmentRoleToken{firstToken, blockedToken, laterToken} {
255+ ticket, admitted := token.ac.beginRoleEffect(token)
256+ require.True(t, admitted, "pre-publication abort changed a role capability")
257+ ticket.End()
258+ requireRoleGateRetired(t, token.ac)
259+ }
260+}
261+
262+func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
263+ d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
264+ snatchedTransport := newTeardownBlockingTransport()
265+ snatched.replaceTransport(snatchedTransport)
266+ d.attachCoordinator(sess, nil, snatched, true)
267+ snatched.publishRoleCapability(sess.attachmentToken(snatched, snatchedTransport))
268+
269+ activeTransport := newTeardownBlockingTransport()
270+ active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: snatched.size}
271+ active.initOverlays()
272+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
273+ target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive,
274+ expectedTransport: active.transportSnapshot(), ready: true,
275+ })
276+ require.NoError(t, err)
277+ defer d.deferAttachmentTransitionCleanups(transition)
278+
279+ unrelatedTransport := &closeTrackingTransport{}
280+ unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size}
281+ unrelatedClient.initOverlays()
282+ unrelated := &session{id: "unrelated", name: "unrelated", ctx: sess.ctx, cancel: func() {}, client: unrelatedClient}
283+ unrelatedClient.setSession(unrelated)
284+ d.mu.Lock()
285+ d.sessions[unrelated.id] = unrelated
286+ d.mu.Unlock()
287+ d.attachCoordinator(unrelated, nil, unrelatedClient, true)
288+
289+ snatchedToken := sess.attachmentToken(snatched, snatchedTransport)
290+ activeToken := sess.attachmentToken(active, activeTransport)
291+ snatchedDone := startBlockedRoleSend(t, snatchedToken)
292+ activeDone := startBlockedRoleSend(t, activeToken)
293+ awaitTestCompletion(t, snatchedTransport.sendEntered, "snatched send did not block")
294+ awaitTestCompletion(t, activeTransport.sendEntered, "active send did not block")
295+
296+ killDone := make(chan error, 1)
297+ go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
298+ select {
299+ case err := <-killDone:
300+ require.NoError(t, err)
301+ case <-time.After(500 * time.Millisecond):
302+ _ = snatchedTransport.Close()
303+ _ = activeTransport.Close()
304+ t.Fatal("session kill remained pinned behind admitted participant sends")
305+ }
306+ awaitTestCompletion(t, snatchedDone, "snatched role ticket did not retire")
307+ awaitTestCompletion(t, activeDone, "active role ticket did not retire")
308+ d.waitNotifies()
309+
310+ require.True(t, snatchedTransport.Closed())
311+ require.True(t, activeTransport.Closed())
312+ require.False(t, unrelatedTransport.Closed(), "unrelated session transport was interrupted")
313+ require.Same(t, unrelatedClient, unrelated.client)
314+ require.Same(t, unrelated, unrelatedClient.currentSession())
315+ requireRoleGateRetired(t, snatched)
316+ requireRoleGateRetired(t, active)
317+}
318+
319+func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *testing.T) {
320+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
321+ stale := newTeardownBlockingTransport()
322+ ac.replaceTransport(stale)
323+ d.attachCoordinator(sess, nil, ac, true)
324+ ac.publishRoleCapability(sess.attachmentToken(ac, stale))
325+
326+ token := sess.attachmentToken(ac, stale)
327+ sendDone := startBlockedRoleSend(t, token)
328+ awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block")
329+ fresh := &closeTrackingTransport{}
330+ d.afterRoleEffectParticipantsSnapshotted = func(string, []*attachedClient) {
331+ ac.replaceTransport(fresh)
332+ }
333+
334+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
335+ awaitTestCompletion(t, sendDone, "captured incarnation role ticket did not retire")
336+ require.True(t, stale.Closed(), "exact captured in-flight transport was not interrupted")
337+ require.False(t, fresh.Closed(), "newer transport incarnation was interrupted")
338+ require.Same(t, fresh, ac.transport())
339+ d.mu.Lock()
340+ require.Same(t, sess, d.sessions[sess.id], "transport incarnation change should abort stale teardown publication")
341+ d.mu.Unlock()
342+ sess.mu.Lock()
343+ require.Same(t, ac, sess.client)
344+ sess.mu.Unlock()
345+ requireRoleGateRetired(t, ac)
346+}
347+
348+func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
349+ d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
350+ blocked := newTeardownBlockingTransport()
351+ active.replaceTransport(blocked)
352+ d.attachCoordinator(sess, nil, active, true)
353+ active.publishRoleCapability(sess.attachmentToken(active, blocked))
354+
355+ token := sess.attachmentToken(active, blocked)
356+ sendDone := startBlockedRoleSend(t, token)
357+ awaitTestCompletion(t, blocked.sendEntered, "active send did not block")
358+
359+ shutdownDone := make(chan bool, 1)
360+ go func() { shutdownDone <- d.shutdownAll(ports.ReasonServerShutdown) }()
361+ select {
362+ case incomplete := <-shutdownDone:
363+ require.False(t, incomplete)
364+ case <-time.After(500 * time.Millisecond):
365+ _ = blocked.Close()
366+ t.Fatal("shutdown remained pinned behind admitted participant send")
367+ }
368+ awaitTestCompletion(t, sendDone, "shutdown-interrupted role ticket did not retire")
369+ d.waitNotifies()
370+ require.True(t, blocked.Closed())
371+ requireRoleGateRetired(t, active)
372+}
373+
374+type renderFailureTransport struct {
375+ mu sync.Mutex
376+ closed bool
377+}
378+
379+func (*renderFailureTransport) Send(ports.Frame) error { return errors.New("render send failed") }
380+func (*renderFailureTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
381+func (t *renderFailureTransport) Close() error {
382+ t.mu.Lock()
383+ t.closed = true
384+ t.mu.Unlock()
385+ return nil
386+}
387+func (t *renderFailureTransport) Closed() bool {
388+ t.mu.Lock()
389+ defer t.mu.Unlock()
390+ return t.closed
391+}
392+
393+func TestRenderSendFailureCleanupRejectsReplacedAndResumedIncarnation(t *testing.T) {
394+ d, sess, original, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
395+ failedTransport := &renderFailureTransport{}
396+ original.replaceTransport(failedTransport)
397+ rc := d.attachCoordinator(sess, nil, original, true)
398+ originalToken := sess.attachmentToken(original, failedTransport)
399+ originalToken.lease = rc.attachmentLease(original)
400+ original.publishRoleCapability(originalToken)
401+
402+ cleanupStarted := make(chan struct{})
403+ releaseCleanup := make(chan struct{})
404+ cleanupDone := make(chan struct{})
405+ d.beforeRoleSendErrorCleanup = func(token attachmentRoleToken) {
406+ require.Equal(t, originalToken.generation, token.generation)
407+ require.Equal(t, originalToken.transport, token.transport)
408+ require.Same(t, originalToken.lease, token.lease)
409+ close(cleanupStarted)
410+ <-releaseCleanup
411+ }
412+ d.afterRoleSendErrorCleanup = func() { close(cleanupDone) }
413+
414+ d.paint(sess, original, true, originalToken.lease)
415+ awaitTestCompletion(t, cleanupStarted, "render failure cleanup did not retain the captured role token")
416+ cleanupTracked := waitGroupDone(&d.attachmentCleanupWg)
417+ select {
418+ case <-cleanupTracked:
419+ t.Fatal("paused render failure cleanup was not tracked by attachmentCleanupWg")
420+ default:
421+ }
422+
423+ replacementTransport := &closeTrackingTransport{}
424+ replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: original.size}
425+ replacement.initOverlays()
426+ displaced, err := d.transitionAttachment(attachmentTransitionRequest{
427+ target: sess, next: replacement, expectedRole: attachmentDetached, targetRole: attachmentActive,
428+ expectedTransport: replacement.transportSnapshot(), ready: true,
429+ })
430+ require.NoError(t, err)
431+
432+ resumed, err := d.transitionAttachment(attachmentTransitionRequest{
433+ target: sess, next: original, expectedRole: attachmentSnatched, targetRole: attachmentActive,
434+ expectedTransport: original.transportSnapshot(), ready: true,
435+ })
436+ require.NoError(t, err)
437+ resumedGeneration := original.roleGeneration.Load()
438+ resumedLease := resumed.published.lease
439+ require.NotSame(t, originalToken.lease, resumedLease)
440+
441+ close(releaseCleanup)
442+ awaitTestCompletion(t, cleanupDone, "render failure cleanup did not finish")
443+ awaitTestCompletion(t, cleanupTracked, "tracked render failure cleanup did not retire")
444+
445+ sess.mu.Lock()
446+ require.Same(t, original, sess.client, "stale render cleanup detached the resumed incarnation")
447+ sess.mu.Unlock()
448+ require.Equal(t, resumedGeneration, original.roleGeneration.Load())
449+ require.True(t, resumed.published.activeCurrent())
450+ require.False(t, failedTransport.Closed(), "stale cleanup closed the resumed incarnation's transport")
451+
452+ d.deferAttachmentTransitionCleanups(displaced)
453+ d.deferAttachmentTransitionCleanups(resumed)
454+ d.attachmentCleanupWg.Wait()
455+}
456+
457+func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing.T) {
458+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
459+ transport := &closeTrackingTransport{}
460+ ac.replaceTransport(transport)
461+ rc := d.attachCoordinator(sess, nil, ac, true)
462+ token := sess.attachmentToken(ac, transport)
463+ token.lease = rc.attachmentLease(ac)
464+ ac.publishRoleCapability(token)
465+
466+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
467+ d.clock = clock
468+ frozen := freezeRoleEffectGates(ac)
469+ defer frozen.unfreeze()
470+ cleanupDone := make(chan struct{})
471+ d.afterRoleSendErrorCleanup = func() { close(cleanupDone) }
472+
473+ launchCleanup := d.reserveRoleSendErrorCleanup(token, transport)
474+ launchCleanup()
475+ deadline := awaitTestValue(t, clock.timers, "render cleanup did not arm its deadline")
476+ require.Equal(t, detachNotifyTimeout, deadline.duration)
477+ deadline.ch <- time.Now()
478+ awaitTestCompletion(t, cleanupDone, "deadline did not release blocked render cleanup")
479+ awaitTestCompletion(t, waitGroupDone(&d.attachmentCleanupWg), "bounded render cleanup leaked its tracked goroutine")
480+
481+ sess.mu.Lock()
482+ require.Same(t, ac, sess.client)
483+ sess.mu.Unlock()
484+ require.False(t, transport.Closed())
485+}
486+
487+func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t *testing.T) {
488+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
489+ oldTransport := newBlockedRenderReplacementTransport(d, sess)
490+ old.replaceTransport(oldTransport)
491+ rc := d.attachCoordinator(sess, nil, old, true)
492+ lease := rc.attachmentLease(old)
493+ token := sess.attachmentToken(old, oldTransport)
494+ token.lease = lease
495+ old.publishRoleCapability(token)
496+
497+ paintDone := make(chan struct{})
498+ go func() {
499+ d.paint(sess, old, true, lease)
500+ close(paintDone)
501+ }()
502+ <-oldTransport.sendEntered
503+
504+ newTransport := &closeTrackingTransport{}
505+ next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}
506+ next.initOverlays()
507+ transitionDone := make(chan attachmentTransitionResult, 1)
508+ transitionErr := make(chan error, 1)
509+ go func() {
510+ result, err := d.transitionAttachment(attachmentTransitionRequest{
511+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
512+ expectedTransport: next.transportSnapshot(), ready: true,
513+ })
514+ transitionDone <- result
515+ transitionErr <- err
516+ }()
517+
518+ var result attachmentTransitionResult
519+ select {
520+ case result = <-transitionDone:
521+ case <-time.After(time.Second):
522+ t.Fatal("replacement publication remained blocked behind the old render send")
523+ }
524+ require.NoError(t, <-transitionErr)
525+ require.True(t, <-oldTransport.locksFree, "transport close ran while an architecture lock was held")
526+ select {
527+ case <-paintDone:
528+ case <-time.After(time.Second):
529+ t.Fatal("closing the exact old transport did not unblock its render")
530+ }
531+ require.Same(t, next, sess.client)
532+ require.False(t, newTransport.Closed(), "replacement transport was affected by old-link interruption")
533+
534+ d.deferAttachmentTransitionCleanups(result)
535+ d.attachmentCleanupWg.Wait()
536+ require.Nil(t, old.transport(), "the unhealthy old link was not retired")
537+ require.Same(t, next, sess.client)
538+ require.False(t, newTransport.Closed())
539+}
540+
541+func TestDisplacedBlockedRenderCleanupWaitsForStaleDetachFreeze(t *testing.T) {
542+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
543+ oldTransport := newBlockedRenderReplacementTransport(d, sess)
544+ old.replaceTransport(oldTransport)
545+ rc := d.attachCoordinator(sess, nil, old, true)
546+ lease := rc.attachmentLease(old)
547+ token := sess.attachmentToken(old, oldTransport)
548+ token.lease = lease
549+ old.publishRoleCapability(token)
550+ old.sendMu.Lock()
551+ old.captureFrames = map[*pane]capturedPaneRenderState{sess.activeTab().focusedPane(): {}}
552+ old.sendMu.Unlock()
553+
554+ staleDetachFrozen := make(chan struct{})
555+ releaseStaleDetach := make(chan struct{})
556+ var releaseOnce sync.Once
557+ t.Cleanup(func() { releaseOnce.Do(func() { close(releaseStaleDetach) }) })
558+ d.afterDetachRoleEffectsFrozen = func() {
559+ close(staleDetachFrozen)
560+ <-releaseStaleDetach
561+ }
562+ cleanupStarted := make(chan struct{})
563+ d.afterDisplacedCleanupStarted = func() { close(cleanupStarted) }
564+
565+ paintDone := make(chan struct{})
566+ go func() {
567+ d.paint(sess, old, true, lease)
568+ close(paintDone)
569+ }()
570+ awaitTestCompletion(t, oldTransport.sendEntered, "old render did not block in transport send")
571+
572+ newTransport := &closeTrackingTransport{}
573+ next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}
574+ next.initOverlays()
575+ transitionDone := make(chan attachmentTransitionResult, 1)
576+ transitionErr := make(chan error, 1)
577+ go func() {
578+ result, err := d.transitionAttachment(attachmentTransitionRequest{
579+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
580+ expectedTransport: next.transportSnapshot(), ready: true,
581+ })
582+ transitionDone <- result
583+ transitionErr <- err
584+ }()
585+
586+ var result attachmentTransitionResult
587+ select {
588+ case result = <-transitionDone:
589+ require.NoError(t, <-transitionErr)
590+ case <-time.After(time.Second):
591+ t.Fatal("replacement did not interrupt the blocked render")
592+ }
593+ awaitTestCompletion(t, paintDone, "interrupted render did not retire")
594+ awaitTestCompletion(t, staleDetachFrozen, "stale send-error detach did not freeze the role gate")
595+
596+ d.deferAttachmentTransitionCleanups(result)
597+ awaitTestCompletion(t, cleanupStarted, "displaced cleanup did not start during stale detach freeze")
598+ releaseOnce.Do(func() { close(releaseStaleDetach) })
599+ select {
600+ case <-waitGroupDone(&d.attachmentCleanupWg):
601+ case <-time.After(time.Second):
602+ t.Fatal("displaced cleanup deadlocked behind stale detach")
603+ }
604+
605+ sess.mu.Lock()
606+ require.Same(t, next, sess.client)
607+ require.NotContains(t, sess.snatched, old, "closed displaced incarnation remained registered")
608+ sess.mu.Unlock()
609+ require.Equal(t, attachmentActive, sess.attachmentRole(next))
610+ require.Nil(t, old.transport(), "closed displaced transport remained attached")
611+ require.Nil(t, old.currentSession(), "terminal displaced cleanup retained session ownership")
612+ old.sendMu.Lock()
613+ require.Empty(t, old.captureFrames, "terminal displaced cleanup retained captures")
614+ old.sendMu.Unlock()
615+ require.False(t, newTransport.Closed(), "stale cleanup affected the new active owner")
616+}
617+
618+func TestInterruptedDisplacedCleanupRejectsNewerReclaimedIncarnation(t *testing.T) {
619+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
620+ staleTransport := &closeTrackingTransport{}
621+ old.replaceTransport(staleTransport)
622+ d.attachCoordinator(sess, nil, old, true)
623+
624+ nextTransport := &closeTrackingTransport{}
625+ next := &attachedClient{tr: nextTransport, output: newOutputStateStream(), size: old.size}
626+ next.initOverlays()
627+ displaced, err := d.transitionAttachment(attachmentTransitionRequest{
628+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
629+ expectedTransport: next.transportSnapshot(), ready: true,
630+ })
631+ require.NoError(t, err)
632+ stale := displaced.displaced
633+
634+ _, err = d.transitionAttachment(attachmentTransitionRequest{
635+ target: sess, next: old, expectedRole: attachmentSnatched, targetRole: attachmentActive,
636+ expectedTransport: old.transportSnapshot(), ready: true,
637+ })
638+ require.NoError(t, err)
639+ freshTransport := &closeTrackingTransport{}
640+ old.replaceTransport(freshTransport)
641+ _, err = d.transitionAttachment(attachmentTransitionRequest{
642+ target: sess, next: old, expectedRole: attachmentActive, targetRole: attachmentActive,
643+ expectedTransport: old.transportSnapshot(), ready: true,
644+ })
645+ require.NoError(t, err)
646+ require.NoError(t, staleTransport.Close())
647+
648+ require.False(t, d.cleanupInterruptedSnatchedAttachment(stale))
649+ sess.mu.Lock()
650+ require.Same(t, old, sess.client)
651+ require.Contains(t, sess.snatched, next)
652+ sess.mu.Unlock()
653+ require.Equal(t, attachmentActive, sess.attachmentRole(old))
654+ require.Same(t, freshTransport, old.transport())
655+ require.False(t, freshTransport.Closed(), "stale terminal cleanup closed the newer transport")
656+ require.False(t, nextTransport.Closed(), "stale terminal cleanup affected the newer displaced owner")
657+}
658+
659+func TestRoleEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *testing.T) {
660+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
661+ oldTransport := newDatagramTestTransport()
662+ old.replaceTransport(oldTransport)
663+ rc := d.attachCoordinator(sess, nil, old, true)
664+ token := sess.attachmentToken(old, oldTransport)
665+ token.lease = rc.attachmentLease(old)
666+ old.publishRoleCapability(token)
667+
668+ ticket, ok := old.beginRoleEffect(token)
669+ require.True(t, ok)
670+
671+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
672+ next.initOverlays()
673+ done := make(chan error, 1)
674+ go func() {
675+ _, err := d.transitionAttachment(attachmentTransitionRequest{
676+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
677+ expectedTransport: next.transportSnapshot(), ready: true,
678+ })
679+ done <- err
680+ }()
681+
682+ select {
683+ case err := <-done:
684+ t.Fatalf("transition committed before admitted effect ended: %v", err)
685+ case <-time.After(20 * time.Millisecond):
686+ }
687+ ticket.End()
688+ require.NoError(t, <-done)
689+}
690+
691+func TestRoleEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *testing.T) {
692+ tests := []struct {
693+ name string
694+ setup func(*Daemon, *session, *attachedClient, *transactionalResizePTY)
695+ frame ports.Frame
696+ assert func(*testing.T, *attachedClient, *transactionalResizePTY)
697+ }{
698+ {
699+ name: "PTY input",
700+ frame: frameInput([]byte("reserved")),
701+ assert: func(t *testing.T, _ *attachedClient, pty *transactionalResizePTY) {
702+ require.Equal(t, [][]byte{[]byte("reserved")}, pty.writes())
703+ },
704+ },
705+ {
706+ name: "mouse input",
707+ setup: func(_ *Daemon, sess *session, _ *attachedClient, _ *transactionalResizePTY) {
708+ p := sess.activeTab().focusedPane()
709+ p.mu.Lock()
710+ p.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
711+ p.mu.Unlock()
712+ },
713+ frame: frameInput([]byte("\x1b[<0;1;2M")),
714+ assert: func(t *testing.T, _ *attachedClient, pty *transactionalResizePTY) {
715+ require.NotEmpty(t, pty.writes())
716+ },
717+ },
718+ {
719+ name: "key action",
720+ frame: frameInput([]byte("\x1b ")),
721+ assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
722+ require.True(t, ac.overlays.paletteActive())
723+ },
724+ },
725+ {
726+ name: "overlay mutation",
727+ setup: func(d *Daemon, sess *session, ac *attachedClient, _ *transactionalResizePTY) {
728+ d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
729+ },
730+ frame: frameInput([]byte("x")),
731+ assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
732+ ac.overlays.promptMu.Lock()
733+ defer ac.overlays.promptMu.Unlock()
734+ require.Equal(t, "x", ac.overlays.prompt.Value())
735+ },
736+ },
737+ {
738+ name: "theme",
739+ frame: ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{
740+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
741+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
742+ })},
743+ assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
744+ require.True(t, ac.getClientTheme().HasFG)
745+ },
746+ },
747+ {
748+ name: "ACK",
749+ setup: func(_ *Daemon, _ *session, ac *attachedClient, _ *transactionalResizePTY) {
750+ ac.output.next = 3
751+ },
752+ frame: ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})},
753+ assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
754+ ac.sendMu.Lock()
755+ defer ac.sendMu.Unlock()
756+ require.Equal(t, uint64(1), ac.output.acked)
757+ },
758+ },
759+ }
760+
761+ for _, tt := range tests {
762+ t.Run(tt.name, func(t *testing.T) {
763+ pty := &transactionalResizePTY{}
764+ d, sess, old, _ := newManualSessionWithPTYs(t, pty)
765+ oldTransport := newDatagramTestTransport()
766+ old.replaceTransport(oldTransport)
767+ old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
768+ rc := d.attachCoordinator(sess, nil, old, true)
769+ token := sess.attachmentToken(old, oldTransport)
770+ token.lease = rc.attachmentLease(old)
771+ if tt.setup != nil {
772+ tt.setup(d, sess, old, pty)
773+ }
774+
775+ admitted := make(chan struct{})
776+ release := make(chan struct{})
777+ d.afterRoleEffectAdmitted = func(attachmentRoleToken) {
778+ close(admitted)
779+ <-release
780+ }
781+ effectDone := make(chan struct{})
782+ go func() {
783+ d.handleActiveClientFrame(token, tt.frame)
784+ close(effectDone)
785+ }()
786+ <-admitted
787+
788+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
789+ next.initOverlays()
790+ transitionDone := make(chan error, 1)
791+ go func() {
792+ _, err := d.transitionAttachment(attachmentTransitionRequest{
793+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
794+ expectedTransport: next.transportSnapshot(), ready: true,
795+ })
796+ transitionDone <- err
797+ }()
798+ select {
799+ case err := <-transitionDone:
800+ t.Fatalf("transition overtook admitted effect: %v", err)
801+ case <-time.After(20 * time.Millisecond):
802+ }
803+ close(release)
804+ <-effectDone
805+ require.NoError(t, <-transitionDone)
806+ tt.assert(t, old, pty)
807+ })
808+ }
809+}
810+
811+func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T) {
812+ d, sess, old, _, releases := newManualTabSession(t, 2)
813+ defer releaseAll(releases)
814+ sess.mu.Lock()
815+ sess.tabs[1].attention = true
816+ sess.tabs[1].attentionAt = time.Unix(1, 0)
817+ sess.mu.Unlock()
818+
819+ oldTransport := old.transport()
820+ rc := d.attachCoordinator(sess, nil, old, true)
821+ token := sess.attachmentToken(old, oldTransport)
822+ token.lease = rc.attachmentLease(old)
823+ old.publishRoleCapability(token)
824+
825+ admissionEnded := make(chan struct{})
826+ releaseAction := make(chan struct{})
827+ d.afterActionRoleEffectEnded = func(action string) {
828+ if action != "jump-attention" {
829+ return
830+ }
831+ close(admissionEnded)
832+ <-releaseAction
833+ }
834+ actionDone := make(chan struct{})
835+ go func() {
836+ d.handleActiveClientFrame(token, frameInput([]byte("\x1ba")))
837+ close(actionDone)
838+ }()
839+ <-admissionEnded
840+
841+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
842+ next.initOverlays()
843+ result, err := d.transitionAttachment(attachmentTransitionRequest{
844+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
845+ expectedTransport: next.transportSnapshot(), ready: true,
846+ })
847+ require.NoError(t, err)
848+ sess.mu.Lock()
849+ sess.active = 0
850+ sess.mu.Unlock()
851+ close(releaseAction)
852+ <-actionDone
853+
854+ require.Equal(t, 0, activeTabIndex(sess), "the replaced initiator mutated source focus after losing its role")
855+ d.deferAttachmentTransitionCleanups(result)
856+}
857+
858+func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
859+ p1, release1 := newBlockingPTY(t)
860+ p2, release2 := newBlockingPTY(t)
861+ p3, release3 := newBlockingPTY(t)
862+ defer release1()
863+ defer release2()
864+ defer release3()
865+ d, source, ac, _ := newManualSessionWithPTYs(t, p1)
866+ target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{
867+ newTab(p2, domain.Size{Cols: 80, Rows: 23}),
868+ newTab(p3, domain.Size{Cols: 80, Rows: 23}),
869+ }}
870+ target.tabs[1].attention = true
871+ target.tabs[1].attentionAt = time.Unix(1, 0)
872+ d.sessions[target.id] = target
873+
874+ rc := d.attachCoordinator(source, nil, ac, true)
875+ token := source.attachmentToken(ac, ac.transport())
876+ token.lease = rc.attachmentLease(ac)
877+ ac.publishRoleCapability(token)
878+
879+ d.handleActiveClientFrame(token, frameInput([]byte("\x1ba")))
880+
881+ require.Same(t, target, ac.currentSession())
882+ require.Equal(t, 1, activeTabIndex(target))
883+}
884+
885+func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T) {
886+ p1, release1 := newBlockingPTY(t)
887+ p2, release2 := newBlockingPTY(t)
888+ p3, release3 := newBlockingPTY(t)
889+ defer release1()
890+ defer release2()
891+ defer release3()
892+ d, source, old, _ := newManualSessionWithPTYs(t, p1)
893+ target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{
894+ newTab(p2, domain.Size{Cols: 80, Rows: 23}),
895+ newTab(p3, domain.Size{Cols: 80, Rows: 23}),
896+ }}
897+ target.tabs[1].attention = true
898+ target.tabs[1].attentionAt = time.Unix(1, 0)
899+ d.sessions[target.id] = target
900+
901+ oldTransport := old.transport()
902+ rc := d.attachCoordinator(source, nil, old, true)
903+ token := source.attachmentToken(old, oldTransport)
904+ token.lease = rc.attachmentLease(old)
905+ old.publishRoleCapability(token)
906+
907+ admissionEnded := make(chan struct{})
908+ releaseAction := make(chan struct{})
909+ d.afterActionRoleEffectEnded = func(action string) {
910+ if action == "jump-attention" {
911+ close(admissionEnded)
912+ <-releaseAction
913+ }
914+ }
915+ actionDone := make(chan struct{})
916+ go func() {
917+ d.handleActiveClientFrame(token, frameInput([]byte("\x1ba")))
918+ close(actionDone)
919+ }()
920+ <-admissionEnded
921+
922+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
923+ next.initOverlays()
924+ result, err := d.transitionAttachment(attachmentTransitionRequest{
925+ target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
926+ expectedTransport: next.transportSnapshot(), ready: true,
927+ })
928+ require.NoError(t, err)
929+ close(releaseAction)
930+ <-actionDone
931+
932+ require.Equal(t, 0, activeTabIndex(target), "stale handoff changed target focus")
933+ target.mu.Lock()
934+ targetClient := target.client
935+ target.mu.Unlock()
936+ require.Nil(t, targetClient, "stale handoff attached the replaced initiator")
937+ d.deferAttachmentTransitionCleanups(result)
938+}
939+
940+func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) {
941+ p, releasePTY := newBlockingPTY(t)
942+ defer releasePTY()
943+ d, sess, old, _ := newManualSessionWithPTYs(t, p)
944+ d.enterPicker(sess, old)
945+
946+ oldTransport := old.transport()
947+ rc := d.attachCoordinator(sess, nil, old, true)
948+ token := sess.attachmentToken(old, oldTransport)
949+ token.lease = rc.attachmentLease(old)
950+ old.publishRoleCapability(token)
951+
952+ admissionEnded := make(chan struct{})
953+ releaseAction := make(chan struct{})
954+ d.afterActionRoleEffectEnded = func(action string) {
955+ if action != "picker-delete" {
956+ return
957+ }
958+ close(admissionEnded)
959+ <-releaseAction
960+ }
961+ actionDone := make(chan struct{})
962+ go func() {
963+ d.handleActiveClientFrame(token, frameInput([]byte("x")))
964+ close(actionDone)
965+ }()
966+ <-admissionEnded
967+
968+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
969+ next.initOverlays()
970+ result, err := d.transitionAttachment(attachmentTransitionRequest{
971+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
972+ expectedTransport: next.transportSnapshot(), ready: true,
973+ })
974+ require.NoError(t, err)
975+ close(releaseAction)
976+ <-actionDone
977+
978+ d.mu.Lock()
979+ registered := d.sessions[sess.id]
980+ d.mu.Unlock()
981+ require.Same(t, sess, registered, "the replaced initiator deleted its former source session")
982+ d.deferAttachmentTransitionCleanups(result)
983+}
984+
985+func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testing.T) {
986+ p1, release1 := newBlockingPTY(t)
987+ p2, release2 := newBlockingPTY(t)
988+ defer release1()
989+ defer release2()
990+ d, source, old, _ := newManualSessionWithPTYs(t, p1)
991+ source.name = "source"
992+ target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
993+ d.sessions[target.id] = target
994+ d.enterPicker(source, old)
995+ old.overlays.pickerMu.Lock()
996+ for {
997+ selected, ok := old.overlays.picker.Selected()
998+ if ok && selected.Session == target.id {
999+ break
1000+ }
1001+ old.overlays.picker.Down()
1002+ }
1003+ old.overlays.pickerMu.Unlock()
1004+
1005+ oldTransport := old.transport()
1006+ rc := d.attachCoordinator(source, nil, old, true)
1007+ token := source.attachmentToken(old, oldTransport)
1008+ token.lease = rc.attachmentLease(old)
1009+ old.publishRoleCapability(token)
1010+
1011+ admissionEnded := make(chan struct{})
1012+ releaseAction := make(chan struct{})
1013+ d.afterActionRoleEffectEnded = func(action string) {
1014+ if action == "picker-delete" {
1015+ close(admissionEnded)
1016+ <-releaseAction
1017+ }
1018+ }
1019+ actionDone := make(chan struct{})
1020+ go func() {
1021+ d.handleActiveClientFrame(token, frameInput([]byte("x")))
1022+ close(actionDone)
1023+ }()
1024+ <-admissionEnded
1025+
1026+ d.mu.Lock()
1027+ _, targetStillRegistered := d.sessions[target.id]
1028+ d.mu.Unlock()
1029+ require.True(t, targetStillRegistered, "picker deletion mutated the target before source-token preflight")
1030+
1031+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1032+ next.initOverlays()
1033+ result, err := d.transitionAttachment(attachmentTransitionRequest{
1034+ target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1035+ expectedTransport: next.transportSnapshot(), ready: true,
1036+ })
1037+ require.NoError(t, err)
1038+ close(releaseAction)
1039+ <-actionDone
1040+ d.mu.Lock()
1041+ _, targetStillRegistered = d.sessions[target.id]
1042+ d.mu.Unlock()
1043+ require.True(t, targetStillRegistered, "stale picker deletion mutated the target session")
1044+ d.deferAttachmentTransitionCleanups(result)
1045+}
1046+
1047+func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
1048+ p, release := newBlockingPTY(t)
1049+ defer release()
1050+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1051+ d.enterPicker(sess, ac)
1052+ rc := d.attachCoordinator(sess, nil, ac, true)
1053+ token := sess.attachmentToken(ac, ac.transport())
1054+ token.lease = rc.attachmentLease(ac)
1055+ ac.publishRoleCapability(token)
1056+
1057+ d.handleActiveClientFrame(token, frameInput([]byte("x")))
1058+
1059+ d.mu.Lock()
1060+ _, registered := d.sessions[sess.id]
1061+ d.mu.Unlock()
1062+ require.False(t, registered)
1063+}
1064+
1065+func TestDelayedKeyCallbackAdmittedBeforeReplacementCompletesFirst(t *testing.T) {
1066+ writes := make(chan []byte, 1)
1067+ pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}
1068+ d, sess, old, _ := newManualSessionWithPTYs(t, pty)
1069+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
1070+ d.clock = clock
1071+ oldTransport := newDatagramTestTransport()
1072+ old.replaceTransport(oldTransport)
1073+ old.keys = keys.NewRouter(clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
1074+ rc := d.attachCoordinator(sess, nil, old, true)
1075+ token := sess.attachmentToken(old, oldTransport)
1076+ token.lease = rc.attachmentLease(old)
1077+ old.publishRoleCapability(token)
1078+
1079+ d.handleActiveClientFrame(token, frameInput([]byte{keys.ESC}))
1080+ timer := <-clock.timers
1081+ admitted := make(chan struct{})
1082+ release := make(chan struct{})
1083+ d.afterRoleEffectAdmitted = func(attachmentRoleToken) {
1084+ close(admitted)
1085+ <-release
1086+ }
1087+ timer.ch <- time.Time{}
1088+ select {
1089+ case <-admitted:
1090+ case <-time.After(time.Second):
1091+ t.Fatal("delayed key callback did not acquire a fresh role-effect ticket")
1092+ }
1093+
1094+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1095+ next.initOverlays()
1096+ transitionDone := make(chan error, 1)
1097+ go func() {
1098+ _, err := d.transitionAttachment(attachmentTransitionRequest{
1099+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1100+ expectedTransport: next.transportSnapshot(), ready: true,
1101+ })
1102+ transitionDone <- err
1103+ }()
1104+ select {
1105+ case err := <-transitionDone:
1106+ t.Fatalf("replacement overtook admitted delayed key callback: %v", err)
1107+ case <-time.After(20 * time.Millisecond):
1108+ }
1109+ close(release)
1110+ require.Equal(t, []byte{keys.ESC}, <-writes)
1111+ require.NoError(t, <-transitionDone)
1112+}
1113+
1114+func TestDelayedKeyCallbackAfterReplacementDropsStaleWork(t *testing.T) {
1115+ writes := make(chan []byte, 1)
1116+ pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}
1117+ d, sess, old, _ := newManualSessionWithPTYs(t, pty)
1118+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
1119+ d.clock = clock
1120+ oldTransport := newDatagramTestTransport()
1121+ old.replaceTransport(oldTransport)
1122+ old.keys = keys.NewRouter(clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
1123+ rc := d.attachCoordinator(sess, nil, old, true)
1124+ token := sess.attachmentToken(old, oldTransport)
1125+ token.lease = rc.attachmentLease(old)
1126+ old.publishRoleCapability(token)
1127+
1128+ d.handleActiveClientFrame(token, frameInput([]byte{keys.ESC}))
1129+ timer := <-clock.timers
1130+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1131+ next.initOverlays()
1132+ _, err := d.transitionAttachment(attachmentTransitionRequest{
1133+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1134+ expectedTransport: next.transportSnapshot(), ready: true,
1135+ })
1136+ require.NoError(t, err)
1137+
1138+ attempted := make(chan bool, 1)
1139+ d.afterDelayedKeyEffectAttempt = func(admitted bool) { attempted <- admitted }
1140+ timer.ch <- time.Time{}
1141+ require.False(t, <-attempted, "stale delayed callback acquired authority after replacement")
1142+ select {
1143+ case got := <-writes:
1144+ t.Fatalf("stale delayed key callback reached the PTY: %q", got)
1145+ default:
1146+ }
1147+}
1148+
1149+func TestRoleEffectGateAdmittedSnatchedPanelFinishesBeforePromotion(t *testing.T) {
1150+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1151+ oldTransport := newDatagramTestTransport()
1152+ old.replaceTransport(oldTransport)
1153+ d.attachCoordinator(sess, nil, old, true)
1154+
1155+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1156+ next.initOverlays()
1157+ replaced, err := d.transitionAttachment(attachmentTransitionRequest{
1158+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1159+ expectedTransport: next.transportSnapshot(), ready: true,
1160+ })
1161+ require.NoError(t, err)
1162+ token := replaced.displaced
1163+
1164+ admitted := make(chan struct{})
1165+ release := make(chan struct{})
1166+ d.afterRoleEffectAdmitted = func(attachmentRoleToken) {
1167+ close(admitted)
1168+ <-release
1169+ }
1170+ effectDone := make(chan struct{})
1171+ wantSize := domain.Size{Cols: 100, Rows: 30}
1172+ go func() {
1173+ d.handleSnatchedClientFrame(token, ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: wantSize})})
1174+ close(effectDone)
1175+ }()
1176+ <-admitted
1177+
1178+ transitionDone := make(chan error, 1)
1179+ go func() {
1180+ _, err := d.transitionAttachment(attachmentTransitionRequest{
1181+ target: sess, next: old, expectedRole: attachmentSnatched, targetRole: attachmentActive,
1182+ expectedTransport: old.transportSnapshot(), ready: true,
1183+ })
1184+ transitionDone <- err
1185+ }()
1186+ select {
1187+ case err := <-transitionDone:
1188+ t.Fatalf("promotion overtook admitted snatched panel: %v", err)
1189+ case <-time.After(20 * time.Millisecond):
1190+ }
1191+ close(release)
1192+ <-effectDone
1193+ require.NoError(t, <-transitionDone)
1194+ require.Equal(t, wantSize, old.size)
1195+}
1196+
1197+func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) {
1198+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1199+ oldTransport := newDatagramTestTransport()
1200+ old.replaceTransport(oldTransport)
1201+ rc := d.attachCoordinator(sess, nil, old, true)
1202+ token := sess.attachmentToken(old, oldTransport)
1203+ token.lease = rc.attachmentLease(old)
1204+
1205+ admitted := make(chan struct{})
1206+ release := make(chan struct{})
1207+ d.afterRoleEffectAdmitted = func(attachmentRoleToken) {
1208+ close(admitted)
1209+ <-release
1210+ }
1211+ paintDone := make(chan bool, 1)
1212+ go func() { paintDone <- d.firstPaintForTransition(token) }()
1213+ <-admitted
1214+
1215+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1216+ next.initOverlays()
1217+ transitionDone := make(chan error, 1)
1218+ go func() {
1219+ _, err := d.transitionAttachment(attachmentTransitionRequest{
1220+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1221+ expectedTransport: next.transportSnapshot(), ready: true,
1222+ })
1223+ transitionDone <- err
1224+ }()
1225+ select {
1226+ case err := <-transitionDone:
1227+ t.Fatalf("replacement overtook admitted first paint: %v", err)
1228+ case <-time.After(20 * time.Millisecond):
1229+ }
1230+ close(release)
1231+ require.True(t, <-paintDone)
1232+ require.NoError(t, <-transitionDone)
1233+}
1234+
1235+func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T) {
1236+ d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1237+ aTransport := newDatagramTestTransport()
1238+ a.replaceTransport(aTransport)
1239+ d.attachCoordinator(first, nil, a, true)
1240+ first.attachmentToken(a, aTransport)
1241+
1242+ bTransport := newDatagramTestTransport()
1243+ b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}
1244+ b.initOverlays()
1245+ second := &session{id: "second", name: "second", client: b, snatched: make(map[*attachedClient]struct{})}
1246+ b.setSession(second)
1247+ d.mu.Lock()
1248+ d.sessions[second.id] = second
1249+ d.mu.Unlock()
1250+ d.attachCoordinator(second, nil, b, true)
1251+ second.attachmentToken(b, bTransport)
1252+
1253+ start := make(chan struct{})
1254+ results := make(chan error, 2)
1255+ go func() {
1256+ <-start
1257+ _, err := d.transitionAttachment(attachmentTransitionRequest{
1258+ source: first, target: second, next: a,
1259+ expectedRole: attachmentActive, targetRole: attachmentActive,
1260+ expectedTransport: a.transportSnapshot(), ready: true,
1261+ })
1262+ results <- err
1263+ }()
1264+ go func() {
1265+ <-start
1266+ _, err := d.transitionAttachment(attachmentTransitionRequest{
1267+ source: second, target: first, next: b,
1268+ expectedRole: attachmentActive, targetRole: attachmentActive,
1269+ expectedTransport: b.transportSnapshot(), ready: true,
1270+ })
1271+ results <- err
1272+ }()
1273+ close(start)
1274+
1275+ errs := make([]error, 0, 2)
1276+ for range 2 {
1277+ select {
1278+ case err := <-results:
1279+ errs = append(errs, err)
1280+ case <-time.After(time.Second):
1281+ t.Fatal("reversed transitions deadlocked")
1282+ }
1283+ }
1284+ successes := 0
1285+ for _, err := range errs {
1286+ if err == nil {
1287+ successes++
1288+ }
1289+ }
1290+ require.Equal(t, 1, successes)
1291+}
1292+
1293+func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
1294+ d, source, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1295+ aTransport := newDatagramTestTransport()
1296+ a.replaceTransport(aTransport)
1297+ aCoordinator := d.attachCoordinator(source, nil, a, true)
1298+
1299+ bTransport := newDatagramTestTransport()
1300+ b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}
1301+ b.initOverlays()
1302+ target := &session{
1303+ id: "picker-delete-target", name: "picker-delete-target", ctx: source.ctx, cancel: func() {},
1304+ client: b, snatched: make(map[*attachedClient]struct{}),
1305+ }
1306+ b.setSession(target)
1307+ d.mu.Lock()
1308+ d.sessions[target.id] = target
1309+ d.mu.Unlock()
1310+ d.attachCoordinator(target, nil, b, true)
1311+
1312+ // Give B the earlier immutable identity. The reverse transition therefore
1313+ // freezes B then A, while the old picker-delete path froze A separately
1314+ // before discovering and freezing B.
1315+ b.roleEffects.immutableOrder()
1316+ a.roleEffects.immutableOrder()
1317+ require.Less(t, b.roleEffects.order.Load(), a.roleEffects.order.Load())
1318+
1319+ token := source.attachmentToken(a, aTransport)
1320+ token.lease = aCoordinator.attachmentLease(a)
1321+ a.publishRoleCapability(token)
1322+ effect, admitted := a.beginRoleEffect(token)
1323+ require.True(t, admitted)
1324+ token = effect.roleToken()
1325+
1326+ deleteDiscovered := make(chan []*attachedClient, 1)
1327+ transitionDiscovered := make(chan []*attachedClient, 1)
1328+ releaseDelete := make(chan struct{})
1329+ releaseTransition := make(chan struct{})
1330+ transitionBFrozen := make(chan struct{})
1331+ deleteFrozen := make(chan *attachedClient, 2)
1332+ d.afterRoleEffectParticipantsSnapshotted = func(action string, participants []*attachedClient) {
1333+ snapshot := append([]*attachedClient(nil), participants...)
1334+ switch action {
1335+ case "picker-delete":
1336+ deleteDiscovered <- snapshot
1337+ <-releaseDelete
1338+ case "reverse-delete-transition":
1339+ transitionDiscovered <- snapshot
1340+ <-releaseTransition
1341+ }
1342+ }
1343+ d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) {
1344+ switch action {
1345+ case "reverse-delete-transition":
1346+ if ac == b {
1347+ close(transitionBFrozen)
1348+ }
1349+ case "picker-delete":
1350+ deleteFrozen <- ac
1351+ }
1352+ }
1353+
1354+ deleteDone := make(chan error, 1)
1355+ go func() {
1356+ deleteDone <- d.killPickerTargetForRole(picker.Target{Session: target.id}, token)
1357+ }()
1358+ deleteParticipants := <-deleteDiscovered
1359+ require.ElementsMatch(t, []*attachedClient{a, b}, deleteParticipants)
1360+
1361+ transitionDone := make(chan error, 1)
1362+ go func() {
1363+ result, err := d.transitionAttachment(attachmentTransitionRequest{
1364+ source: target, target: source, next: b,
1365+ expectedRole: attachmentActive, targetRole: attachmentActive,
1366+ expectedTransport: b.transportSnapshot(), action: "reverse-delete-transition", ready: true,
1367+ })
1368+ if err == nil {
1369+ d.deferAttachmentTransitionCleanups(result)
1370+ }
1371+ transitionDone <- err
1372+ }()
1373+ transitionParticipants := <-transitionDiscovered
1374+ require.ElementsMatch(t, []*attachedClient{a, b}, transitionParticipants)
1375+
1376+ close(releaseTransition)
1377+ select {
1378+ case <-transitionBFrozen:
1379+ case <-time.After(time.Second):
1380+ t.Fatal("reverse transition did not freeze B first")
1381+ }
1382+ close(releaseDelete)
1383+
1384+ select {
1385+ case err := <-transitionDone:
1386+ require.NoError(t, err)
1387+ case <-time.After(time.Second):
1388+ t.Fatal("reverse transition deadlocked with picker deletion")
1389+ }
1390+ select {
1391+ case err := <-deleteDone:
1392+ require.NoError(t, err)
1393+ case <-time.After(time.Second):
1394+ t.Fatal("picker deletion deadlocked with reverse transition")
1395+ }
1396+ frozenByDelete := make([]*attachedClient, 0, 2)
1397+ for range 2 {
1398+ select {
1399+ case ac := <-deleteFrozen:
1400+ frozenByDelete = append(frozenByDelete, ac)
1401+ case <-time.After(time.Second):
1402+ t.Fatal("picker deletion did not freeze every participant")
1403+ }
1404+ }
1405+ require.ElementsMatch(t, []*attachedClient{a, b}, frozenByDelete, "picker deletion must freeze each deduplicated participant exactly once")
1406+ require.Empty(t, deleteFrozen)
1407+
1408+ d.mu.Lock()
1409+ registered := d.sessions[target.id]
1410+ d.mu.Unlock()
1411+ require.Same(t, target, registered, "stale picker deletion mutated its target lifecycle")
1412+ require.Equal(t, attachmentSnatched, source.attachmentRole(a))
1413+ require.Equal(t, attachmentActive, source.attachmentRole(b))
1414+}
1415+
1416+func TestRoleEffectGateFrozenTransitionRejectsLateEffect(t *testing.T) {
1417+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1418+ oldTransport := newDatagramTestTransport()
1419+ old.replaceTransport(oldTransport)
1420+ rc := d.attachCoordinator(sess, nil, old, true)
1421+ token := sess.attachmentToken(old, oldTransport)
1422+ token.lease = rc.attachmentLease(old)
1423+ old.publishRoleCapability(token)
1424+
1425+ frozen := make(chan struct{})
1426+ release := make(chan struct{})
1427+ d.afterRoleEffectsFrozen = func() {
1428+ close(frozen)
1429+ <-release
1430+ }
1431+
1432+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1433+ next.initOverlays()
1434+ done := make(chan error, 1)
1435+ go func() {
1436+ _, err := d.transitionAttachment(attachmentTransitionRequest{
1437+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1438+ expectedTransport: next.transportSnapshot(), ready: true,
1439+ })
1440+ done <- err
1441+ }()
1442+ <-frozen
1443+
1444+ _, ok := old.beginRoleEffect(token)
1445+ require.False(t, ok, "a frozen capability must admit no new effect")
1446+ close(release)
1447+ require.NoError(t, <-done)
1448+}
M internal/usecase/daemon/session.go +375 -55
@@ -52,7 +52,7 @@ type session struct {
52 teardownWaiters uint52 teardownWaiters uint
53 teardownChanged chan struct{}53 teardownChanged chan struct{}
54 lifecycleStopOnce sync.Once54 lifecycleStopOnce sync.Once
55- mu sync.Mutex // guards tabs, active, client, restoreDone, clipFiles, and clipboard queue state55+ mu sync.Mutex // guards tabs, active, client, snatched, restoreDone, clipFiles, and clipboard queue state
56 // restoreDone remains attached to a restored session after it is published in56 // restoreDone remains attached to a restored session after it is published in
57 // the live registry, so racing attaches cannot bypass restoration completion.57 // the live registry, so racing attaches cannot bypass restoration completion.
58 restoreDone chan struct{}58 restoreDone chan struct{}
@@ -60,6 +60,7 @@ type session struct {
60 tabs []*tab60 tabs []*tab
61 active int61 active int
62 client *attachedClient62 client *attachedClient
63+ snatched map[*attachedClient]struct{}
63 clipboardQueue []clipboardForward64 clipboardQueue []clipboardForward
64 clipboardWorkerRunning bool65 clipboardWorkerRunning bool
65 cwd string66 cwd string
@@ -361,55 +362,95 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
361 d.mu.Unlock()362 d.mu.Unlock()
362 return errSessionNameInUse363 return errSessionNameInUse
363 }364 }
364- // The caller owns d.mu. Make the entire ownership handoff atomic with
365- // global routing so it cannot select a client between source removal and
366- // destination publication.
367- d.notices.routingMu.Lock()
368 from.mu.Lock()365 from.mu.Lock()
369 cwd := from.cwd366 cwd := from.cwd
370 term := from.terminal367 term := from.terminal
371 env := copyEnvironment(from.env)368 env := copyEnvironment(from.env)
372 if from.client != ac {369 if from.client != ac {
373 from.mu.Unlock()370 from.mu.Unlock()
374- d.notices.routingMu.Unlock()
375 d.mu.Unlock()371 d.mu.Unlock()
376 return errors.New("client detached")372 return errors.New("client detached")
377 }373 }
378 from.mu.Unlock()374 from.mu.Unlock()
379 375
380 newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env)376 newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env)
377+ d.mu.Unlock()
381 if err != nil {378 if err != nil {
382- d.notices.routingMu.Unlock()
383- d.mu.Unlock()
384 return err379 return err
385 }380 }
386- from.mu.Lock()381+ // The newly created target is registered before the role transition, but
387- if from.client != ac {382+ // remains headless. Freeze/drain must occur with no daemon/routing/session
388- from.mu.Unlock()383+ // lock held; the transition revalidates both lifecycles before publication.
389- d.notices.routingMu.Unlock()384+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
390- d.mu.Unlock()385+ source: from,
386+ target: newSess,
387+ next: ac,
388+ expectedRole: attachmentActive,
389+ targetRole: attachmentActive,
390+ expectedTransport: ac.transportSnapshot(),
391+ ready: true,
392+ })
393+ if err != nil {
391 _ = d.killSession(newSess, ports.ReasonSessionKilled, true)394 _ = d.killSession(newSess, ports.ReasonSessionKilled, true)
392 return errors.New("client detached")395 return errors.New("client detached")
393 }396 }
394- from.client = nil
395- ac.setSession(nil)
396- from.mu.Unlock()
397-
398- // Prepare source invalidation, output rebase, and destination coordinator
399- // identity before publishing the destination attachment.
400- cleanups := d.handoffCoordinator(from, newSess, nil, ac)
401- ac.setSession(newSess)
402- newSess.mu.Lock()
403- newSess.client = ac
404- newSess.mu.Unlock()
405 397
406 d.touchMRU(newSess)398 d.touchMRU(newSess)
407 ac.recordPreviousSession(from)399 ac.recordPreviousSession(from)
408 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)400 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
409- d.notices.routingMu.Unlock()401+ d.deferAttachmentTransitionCleanups(transition)
410- d.mu.Unlock()402+ d.firstPaintForTransition(transition.published)
411- finishRenderLifecycleCleanups(cleanups)403+ return nil
412- d.firstPaint(newSess, ac, sz)404+}
405+
406+// createSessionAndSwitchForRole creates no target until the initiating role
407+// ticket has ended and the exact source capability is frozen and revalidated.
408+func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name string) error {
409+ if name == "" {
410+ return errSessionNameRequired
411+ }
412+ if err := domain.ValidateSessionName(name); err != nil {
413+ return err
414+ }
415+ if token.sess == nil || token.ac == nil || token.effect == nil || token.role != attachmentActive {
416+ return errAttachmentTransition
417+ }
418+ token.effect.bindActionEnd(d, "create-session")
419+ token.effect.End()
420+
421+ var created *session
422+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
423+ source: token.sess, next: token.ac,
424+ expectedRole: attachmentActive, targetRole: attachmentActive,
425+ expectedTransport: token.transport, sourceToken: &token, action: "create-session",
426+ copySourceEnvironment: true, ready: true,
427+ createTargetLocked: func() (*session, error) {
428+ if d.closing {
429+ return nil, errors.New("daemon is shutting down")
430+ }
431+ if d.nameLiveOrStoppedLocked(name) {
432+ return nil, errSessionNameInUse
433+ }
434+ token.sess.mu.Lock()
435+ cwd, term, env := token.sess.cwd, token.sess.terminal, copyEnvironment(token.sess.env)
436+ token.sess.mu.Unlock()
437+ var createErr error
438+ created, createErr = d.createSessionLocked(name, false, cwd, token.ac.size, term, env)
439+ return created, createErr
440+ },
441+ })
442+ if err != nil {
443+ if created != nil {
444+ _ = d.killSession(created, ports.ReasonSessionKilled, true)
445+ }
446+ return err
447+ }
448+
449+ d.touchMRU(created)
450+ token.ac.recordPreviousSession(token.sess)
451+ d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
452+ d.deferAttachmentTransitionCleanups(transition)
453+ d.firstPaintForTransition(transition.published)
413 return nil454 return nil
414 }455 }
415 456
@@ -598,13 +639,79 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool {
598 return false639 return false
599 }640 }
600 641
601-// detachIfCurrent serializes client removal with global notice routing. The642+// detachIfCurrent publishes terminal role invalidation through the attachment
602-// routing lock is released before the caller performs teardown, so it covers643+// gate. Freeze/drain happens before routing or session locks are acquired.
603-// only attachment ownership and is never held across transport work.
604 func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {644 func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
645+ if sess == nil || ac == nil {
646+ return false
647+ }
648+ frozen := freezeRoleEffectGates(ac)
649+ defer frozen.unfreeze()
650+ if d.afterDetachRoleEffectsFrozen != nil {
651+ d.afterDetachRoleEffectsFrozen()
652+ }
605 d.notices.routingMu.Lock()653 d.notices.routingMu.Lock()
606- defer d.notices.routingMu.Unlock()654+ sess.mu.Lock()
607- return sess.detachIfCurrent(ac)655+ current := sess.client == ac && ac.currentSession() == sess
656+ if current {
657+ sess.client = nil
658+ ac.roleGeneration.Add(1)
659+ ac.setSession(nil)
660+ ac.invalidateFrozenRoleCapability()
661+ }
662+ sess.mu.Unlock()
663+ d.notices.routingMu.Unlock()
664+ return current
665+}
666+
667+// detachIfRoleCurrent invalidates only the exact role generation and transport
668+// whose admitted send failed. A queued stale error cannot detach a later role
669+// publication or a rebound transport on the same attachment object.
670+func (d *Daemon) detachIfRoleCurrent(token attachmentRoleToken) bool {
671+ return d.detachIfRoleCurrentUntil(token, nil)
672+}
673+
674+func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() <-chan struct{}) bool {
675+ if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {
676+ return false
677+ }
678+ frozen := freezeRoleEffectGatesInterruptingObservedUntil(nil, done, nil, token.ac)
679+ defer frozen.unfreeze()
680+ if !frozen.acquired || !frozen.drained {
681+ return false
682+ }
683+ if d.afterDetachRoleEffectsFrozen != nil {
684+ d.afterDetachRoleEffectsFrozen()
685+ }
686+ d.mu.Lock()
687+ if d.sessions[token.sess.id] != token.sess {
688+ d.mu.Unlock()
689+ return false
690+ }
691+ d.notices.routingMu.Lock()
692+ token.sess.mu.Lock()
693+ coordinator := token.sess.renderCoordinator()
694+ if coordinator != nil {
695+ coordinator.mu.Lock()
696+ }
697+ req := attachmentTransitionRequest{
698+ source: token.sess, next: token.ac, expectedRole: token.role,
699+ expectedTransport: token.transport,
700+ }
701+ current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
702+ if current {
703+ token.sess.client = nil
704+ token.ac.roleGeneration.Add(1)
705+ token.ac.setSession(nil)
706+ token.ac.invalidateFrozenRoleCapability()
707+ }
708+ if coordinator != nil {
709+ coordinator.mu.Unlock()
710+ }
711+ token.sess.mu.Unlock()
712+ d.notices.routingMu.Unlock()
713+ d.mu.Unlock()
714+ return current
608 }715 }
609 716
610 func (s *session) activeTab() *tab {717 func (s *session) activeTab() *tab {
@@ -921,7 +1028,145 @@ func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) erro
921 }1028 }
922 1029
923 func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {1030 func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
924- return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil)1031+ return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, nil)
1032+}
1033+
1034+type sessionKillAdmission struct {
1035+ token attachmentRoleToken
1036+ action string
1037+}
1038+
1039+type sessionKillParticipants struct {
1040+ active *attachedClient
1041+ snatched map[*attachedClient]struct{}
1042+ transports map[*attachedClient]transportSnapshot
1043+ roleGates []*attachedClient
1044+ interrupts []roleTransportInterrupt
1045+ source *session
1046+ sourceToken *attachmentRoleToken
1047+ target *session
1048+}
1049+
1050+func (d *Daemon) killSessionForRole(sess *session, reason uint8, purge bool, token attachmentRoleToken, action string) error {
1051+ return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{token: token, action: action})
1052+}
1053+
1054+// snapshotSessionKillParticipants captures role membership under the canonical
1055+// architecture locks, but releases every lock before any role gate is frozen or
1056+// drained. The initiator is part of the same deduplicated global gate set even
1057+// when it belongs to a different source session.
1058+func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission) (sessionKillParticipants, bool) {
1059+ snapshot := sessionKillParticipants{target: target}
1060+ if admission != nil {
1061+ token := admission.token
1062+ if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {
1063+ return sessionKillParticipants{}, false
1064+ }
1065+ snapshot.source = token.sess
1066+ snapshot.sourceToken = &admission.token
1067+ }
1068+
1069+ d.mu.Lock()
1070+ if d.sessions[target.id] != target || snapshot.source != nil && d.sessions[snapshot.source.id] != snapshot.source {
1071+ d.mu.Unlock()
1072+ return sessionKillParticipants{}, false
1073+ }
1074+ d.notices.routingMu.Lock()
1075+ source := snapshot.source
1076+ if source == nil {
1077+ source = target
1078+ }
1079+ unlockSessions := lockAttachmentSessions(source, target)
1080+ snapshot.active = target.client
1081+ snapshot.snatched = make(map[*attachedClient]struct{}, len(target.snatched))
1082+ snapshot.transports = make(map[*attachedClient]transportSnapshot, 1+len(target.snatched))
1083+ if snapshot.active != nil {
1084+ snapshot.transports[snapshot.active] = snapshot.active.transportSnapshot()
1085+ }
1086+ for ac := range target.snatched {
1087+ snapshot.snatched[ac] = struct{}{}
1088+ snapshot.transports[ac] = ac.transportSnapshot()
1089+ }
1090+ unlockSessions()
1091+ d.notices.routingMu.Unlock()
1092+ d.mu.Unlock()
1093+
1094+ seen := make(map[*attachedClient]struct{}, 2+len(snapshot.snatched))
1095+ add := func(ac *attachedClient) {
1096+ if ac == nil {
1097+ return
1098+ }
1099+ if _, duplicate := seen[ac]; duplicate {
1100+ return
1101+ }
1102+ seen[ac] = struct{}{}
1103+ snapshot.roleGates = append(snapshot.roleGates, ac)
1104+ }
1105+ if admission != nil {
1106+ add(admission.token.ac)
1107+ }
1108+ add(snapshot.active)
1109+ for ac := range snapshot.snatched {
1110+ add(ac)
1111+ }
1112+ for ac, transport := range snapshot.transports {
1113+ if transport.transport != nil {
1114+ snapshot.interrupts = append(snapshot.interrupts, roleTransportInterrupt{ac: ac, transport: transport})
1115+ }
1116+ }
1117+ return snapshot, true
1118+}
1119+
1120+// sessionKillParticipantsCurrent repeats lifecycle, source-token, and exact
1121+// target-membership validation while every discovered role gate is frozen.
1122+func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants) bool {
1123+ d.mu.Lock()
1124+ defer d.mu.Unlock()
1125+ if d.sessions[snapshot.target.id] != snapshot.target ||
1126+ snapshot.source != nil && d.sessions[snapshot.source.id] != snapshot.source {
1127+ return false
1128+ }
1129+ d.notices.routingMu.Lock()
1130+ defer d.notices.routingMu.Unlock()
1131+ source := snapshot.source
1132+ if source == nil {
1133+ source = snapshot.target
1134+ }
1135+ sourceCoordinator := source.renderCoordinator()
1136+ targetCoordinator := snapshot.target.renderCoordinator()
1137+ unlockSessions := lockAttachmentSessions(source, snapshot.target)
1138+ defer unlockSessions()
1139+ unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, snapshot.target, targetCoordinator)
1140+ defer unlockCoordinators()
1141+ return sessionKillParticipantsCurrentLocked(snapshot, sourceCoordinator)
1142+}
1143+
1144+// sessionKillParticipantsCurrentLocked requires the ordered source/target
1145+// session and coordinator locks.
1146+func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool {
1147+ target := snapshot.target
1148+ if target.client != snapshot.active || len(target.snatched) != len(snapshot.snatched) {
1149+ return false
1150+ }
1151+ for ac := range snapshot.snatched {
1152+ if _, current := target.snatched[ac]; !current {
1153+ return false
1154+ }
1155+ }
1156+ for ac, transport := range snapshot.transports {
1157+ if !ac.transportSnapshotCurrent(transport) {
1158+ return false
1159+ }
1160+ }
1161+ if snapshot.sourceToken == nil {
1162+ return true
1163+ }
1164+ token := *snapshot.sourceToken
1165+ req := attachmentTransitionRequest{
1166+ source: token.sess, next: token.ac, expectedRole: token.role,
1167+ expectedTransport: token.transport,
1168+ }
1169+ return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)
925 }1170 }
926 1171
927 // stopInMemoryLifecycle cancels every session producer and closes every PTY1172 // stopInMemoryLifecycle cancels every session producer and closes every PTY
@@ -1023,10 +1268,52 @@ func (s *session) finishTeardown() {
1023 // terminal checkpoint and coordinator join. A timed-out repository call keeps1268 // terminal checkpoint and coordinator join. A timed-out repository call keeps
1024 // only immutable capture state; it never observes closed worker channels or1269 // only immutable capture state; it never observes closed worker channels or
1025 // session-owned cache that teardown has released.1270 // session-owned cache that teardown has released.
1026-func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline) error {1271+func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline, admission *sessionKillAdmission) error {
1027 if sess == nil {1272 if sess == nil {
1028 return nil1273 return nil
1029 }1274 }
1275+ // Discover every role whose membership can be invalidated before freezing
1276+ // any one of them. The common gate helper deduplicates and freezes the whole
1277+ // immutable identity set in the same global order used by transitions.
1278+ participants, ok := d.snapshotSessionKillParticipants(sess, admission)
1279+ if !ok {
1280+ return nil
1281+ }
1282+ if admission != nil {
1283+ d.endActionRoleEffect(admission.token.effect, admission.action)
1284+ }
1285+ action := ""
1286+ if admission != nil {
1287+ action = admission.action
1288+ }
1289+ if d.afterRoleEffectParticipantsSnapshotted != nil {
1290+ d.afterRoleEffectParticipantsSnapshotted(action, participants.roleGates)
1291+ }
1292+ var drainDone func() <-chan struct{}
1293+ var drainDeadline *roleEffectDrainDeadline
1294+ if deadline != nil {
1295+ drainDone = deadline.Done
1296+ } else {
1297+ drainDeadline = newRoleEffectDrainDeadline(d.clock)
1298+ drainDone = drainDeadline.Done
1299+ defer drainDeadline.stop()
1300+ }
1301+ frozen := freezeRoleEffectGatesInterruptingObservedUntil(participants.interrupts, drainDone, func(ac *attachedClient) {
1302+ if d.afterRoleEffectGateFrozen != nil {
1303+ d.afterRoleEffectGateFrozen(action, ac)
1304+ }
1305+ }, participants.roleGates...)
1306+ defer func() { frozen.unfreeze() }()
1307+ if !frozen.acquired {
1308+ // The acquisition helper has already rolled back exactly the gates it
1309+ // owned. Do not enter architecture locks or publish terminal ownership
1310+ // changes unless the complete canonical participant set is frozen.
1311+ return nil
1312+ }
1313+ if !d.sessionKillParticipantsCurrent(participants) {
1314+ return nil
1315+ }
1316+
1030 // Pane EOF, explicit close, and daemon shutdown can all converge on the same1317 // Pane EOF, explicit close, and daemon shutdown can all converge on the same
1031 // session. Only one owner may create the terminal mutation and checkpoint;1318 // session. Only one owner may create the terminal mutation and checkpoint;
1032 // later callers observe the completed registry transition instead of1319 // later callers observe the completed registry transition instead of
@@ -1119,21 +1406,62 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1119 sess.snapshotMu.Unlock()1406 sess.snapshotMu.Unlock()
1120 }1407 }
1121 }1408 }
1409+ // Revalidate the exact source capability, target lifecycle, and active plus
1410+ // snatched memberships under canonical architecture locks immediately before
1411+ // publication. A changed preflight aborts without mutating either lifecycle.
1122 d.mu.Lock()1412 d.mu.Lock()
1123- if _, ok := d.sessions[sess.id]; !ok {1413+ if d.sessions[sess.id] != sess || participants.source != nil && d.sessions[participants.source.id] != participants.source {
1124 d.mu.Unlock()1414 d.mu.Unlock()
1125 return nil1415 return nil
1126 }1416 }
1417+ d.notices.routingMu.Lock()
1418+ source := participants.source
1419+ if source == nil {
1420+ source = sess
1421+ }
1422+ sourceCoordinator := source.renderCoordinator()
1423+ targetCoordinator := sess.renderCoordinator()
1424+ unlockSessions := lockAttachmentSessions(source, sess)
1425+ unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, sess, targetCoordinator)
1426+ if !sessionKillParticipantsCurrentLocked(participants, sourceCoordinator) {
1427+ unlockCoordinators()
1428+ unlockSessions()
1429+ d.notices.routingMu.Unlock()
1430+ d.mu.Unlock()
1431+ return nil
1432+ }
1433+
1434+ // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held
1435+ // here. Registry removal and every role invalidation are one publication
1436+ // while the complete immutable gate set remains frozen.
1127 delete(d.sessions, sess.id)1437 delete(d.sessions, sess.id)
1128 d.clearBarScriptsForSession(sess.id)1438 d.clearBarScriptsForSession(sess.id)
1129 d.purgeParkedForSessionLocked(sess)1439 d.purgeParkedForSessionLocked(sess)
1130- sess.mu.Lock()1440+ detachedActive := sess.client
1441+ attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates))
1442+ capture := func(attached *attachedClient) {
1443+ if attached == nil {
1444+ return
1445+ }
1446+ attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]})
1447+ attached.roleGeneration.Add(1)
1448+ attached.setSession(nil)
1449+ attached.invalidateFrozenRoleCapability()
1450+ }
1451+ capture(sess.client)
1452+ for snatched := range sess.snatched {
1453+ capture(snatched)
1454+ }
1455+ sess.client = nil
1456+ clear(sess.snatched)
1131 stoppedName := sess.name1457 stoppedName := sess.name
1132 stoppedCwd := sess.cwd1458 stoppedCwd := sess.cwd
1133 createdAt := sess.createdAt1459 createdAt := sess.createdAt
1134 tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames1460 tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames
1135 ephemeral := sess.ephemeral1461 ephemeral := sess.ephemeral
1136- sess.mu.Unlock()1462+ unlockCoordinators()
1463+ unlockSessions()
1464+ d.notices.routingMu.Unlock()
1137 if !ephemeral {1465 if !ephemeral {
1138 stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}1466 stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
1139 d.stopped[stoppedName] = stopped1467 d.stopped[stoppedName] = stopped
@@ -1146,18 +1474,11 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1146 d.closing = true1474 d.closing = true
1147 }1475 }
1148 d.mu.Unlock()1476 d.mu.Unlock()
1477+ frozen.unfreeze()
1478+ frozen = frozenRoleEffectGates{}
1149 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)1479 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
1150 1480
1151 var purgeErr error1481 var purgeErr error
1152-
1153- // A global route that selected this attachment must publish before terminal
1154- // teardown makes it stale.
1155- d.notices.routingMu.Lock()
1156- sess.mu.Lock()
1157- ac := sess.client
1158- sess.client = nil
1159- sess.mu.Unlock()
1160- d.notices.routingMu.Unlock()
1161 if rc := sess.renderCoordinator(); rc != nil {1482 if rc := sess.renderCoordinator(); rc != nil {
1162 // Terminal teardown has two phases: this session owner first prevents any1483 // Terminal teardown has two phases: this session owner first prevents any
1163 // new worker registration and detaches all tokens, then stops and waits1484 // new worker registration and detaches all tokens, then stops and waits
@@ -1165,11 +1486,10 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1165 rc.beginSessionTeardown().finish()1486 rc.beginSessionTeardown().finish()
1166 rc.waitForTimerWorkers()1487 rc.waitForTimerWorkers()
1167 }1488 }
1168- if ac != nil {1489+ for _, attachment := range attachments {
1169- d.unregisterPreview(ac)1490+ d.unregisterPreview(attachment.ac)
1170- ac.clearPreviousSession()1491+ attachment.ac.clearPreviousSession()
1171- ac.setSession(nil)1492+ attachment.ac.clearCaptureFrames()
1172- ac.clearCaptureFrames()
1173 }1493 }
1174 1494
1175 // Prevent queued launches from entering Open, cancel the parent context, and1495 // Prevent queued launches from entering Open, cancel the parent context, and
@@ -1183,7 +1503,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1183 sess.mu.Unlock()1503 sess.mu.Unlock()
1184 for _, tb := range tabs {1504 for _, tb := range tabs {
1185 d.clearDestroyedTabPreview(tb)1505 d.clearDestroyedTabPreview(tb)
1186- d.teardownFloating(tb, ac)1506+ d.teardownFloating(tb, detachedActive)
1187 }1507 }
1188 for _, path := range clipFiles {1508 for _, path := range clipFiles {
1189 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {1509 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
@@ -1212,8 +1532,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1212 d.repaintAllAttachedClients()1532 d.repaintAllAttachedClients()
1213 }1533 }
1214 1534
1215- if ac != nil {1535+ for _, attachment := range attachments {
1216- d.notifyDetachedAsync(ac, reason)1536+ d.notifyDetachedSnapshotAsync(attachment, reason)
1217 }1537 }
1218 return errors.Join(purgeErr, terminalSnapshotErr)1538 return errors.Join(purgeErr, terminalSnapshotErr)
1219 }1539 }
M internal/usecase/daemon/session_back.go +12 -0
@@ -4,6 +4,18 @@ import "github.com/bnema/vev/internal/usecase/picker"
4 4
5 // backSession toggles this attachment between its current session and the5 // backSession toggles this attachment between its current session and the
6 // immediately preceding successfully activated session.6 // immediately preceding successfully activated session.
7+func (d *Daemon) backSessionForRole(token attachmentRoleToken) error {
8+ if d == nil || token.sess == nil || token.ac == nil {
9+ return nil
10+ }
11+ target := token.ac.previousSession.Get()
12+ if target == nil || target == token.sess || d.sessionByID(target.id) != target {
13+ token.ac.clearPreviousSessionIf(target)
14+ return nil
15+ }
16+ return d.switchToTargetForRole(token, picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "back-session")
17+}
18+
7 func (d *Daemon) backSession(current *session, ac *attachedClient) {19 func (d *Daemon) backSession(current *session, ac *attachedClient) {
8 if d == nil || current == nil || ac == nil {20 if d == nil || current == nil || ac == nil {
9 return21 return
M internal/usecase/daemon/session_test.go +9 -7
@@ -283,7 +283,7 @@ func TestEphemeralNumberingReuse(t *testing.T) {
283 283
284 // --- attach replace ---------------------------------------------------------284 // --- attach replace ---------------------------------------------------------
285 285
286-func TestAttachReplaceDetachesOld(t *testing.T) {286+func TestAttachReplaceKeepsOldClientSnatched(t *testing.T) {
287 p, releasePTY := newBlockingPTY(t)287 p, releasePTY := newBlockingPTY(t)
288 d := newTestDaemon(t, newFactory(t, p), stubClock{})288 d := newTestDaemon(t, newFactory(t, p), stubClock{})
289 289
@@ -295,21 +295,23 @@ func TestAttachReplaceDetachesOld(t *testing.T) {
295 awaitFrame(t, sendsA, ports.MsgOutput)295 awaitFrame(t, sendsA, ports.MsgOutput)
296 sess := firstSession(d)296 sess := firstSession(d)
297 require.NotNil(t, sess)297 require.NotNil(t, sess)
298+ sess.mu.Lock()
299+ acA := sess.client
300+ sess.mu.Unlock()
301+ require.NotNil(t, acA)
298 302
299 // Client B attaches to the same session, displacing A.303 // Client B attaches to the same session, displacing A.
300 trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24}))304 trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24}))
301 hg.Go(func() { d.handleConn(trB) })305 hg.Go(func() { d.handleConn(trB) })
302 awaitFrame(t, sendsB, ports.MsgWelcome)306 awaitFrame(t, sendsB, ports.MsgWelcome)
303 307
304- // A is notified it was detached.308+ // B is now the sole active client while A remains a live snatched member.
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.
310 sess.mu.Lock()309 sess.mu.Lock()
311 require.NotNil(t, sess.client)310 require.NotNil(t, sess.client)
311+ require.NotSame(t, acA, sess.client)
312+ require.Contains(t, sess.snatched, acA)
312 sess.mu.Unlock()313 sess.mu.Unlock()
314+ require.Same(t, sess, acA.currentSession())
313 315
314 releaseA()316 releaseA()
315 releaseB()317 releaseB()
A internal/usecase/daemon/snatched_client.go +741 -0
@@ -0,0 +1,741 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+type attachmentRole uint8
10+
11+const (
12+ attachmentDetached attachmentRole = iota
13+ attachmentActive
14+ attachmentSnatched
15+)
16+
17+// attachmentRole derives the attachment's role from the session-owned
18+// registries. There is deliberately no second role field that could drift.
19+func (s *session) attachmentRole(ac *attachedClient) attachmentRole {
20+ if s == nil || ac == nil {
21+ return attachmentDetached
22+ }
23+ s.mu.Lock()
24+ defer s.mu.Unlock()
25+ return s.attachmentRoleLocked(ac)
26+}
27+
28+// attachmentRoleLocked requires s.mu. Active membership wins if an invalid
29+// intermediate state contains the same attachment in both registries.
30+func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole {
31+ if ac == nil {
32+ return attachmentDetached
33+ }
34+ if s.client == ac {
35+ return attachmentActive
36+ }
37+ if _, ok := s.snatched[ac]; ok {
38+ return attachmentSnatched
39+ }
40+ return attachmentDetached
41+}
42+
43+func (s *session) addSnatchedLocked(ac *attachedClient) {
44+ if ac == nil {
45+ return
46+ }
47+ if s.snatched == nil {
48+ s.snatched = make(map[*attachedClient]struct{})
49+ }
50+ s.snatched[ac] = struct{}{}
51+}
52+
53+var errAttachmentTransition = errors.New("attachment transition is no longer valid")
54+
55+type attachmentTransitionRequest struct {
56+ source *session
57+ target *session
58+ next *attachedClient
59+ expectedRole attachmentRole
60+ targetRole attachmentRole
61+ expectedTransport transportSnapshot
62+ sourceToken *attachmentRoleToken
63+ action string
64+ activateTargetTab bool
65+ targetTabIndex int
66+ copySourceEnvironment bool
67+ // preserveRole commits navigation state without replacing attachment
68+ // ownership. It is used for same-session picker selections after the exact
69+ // initiating capability has been frozen and revalidated.
70+ preserveRole bool
71+ expectedSourceTab *tab
72+ createTargetLocked func() (*session, error)
73+ ready bool
74+ expectedTargetCurrent *attachedClient
75+ expectedTargetTransport transportSnapshot
76+ preflighted bool
77+ roleEffectsFrozen bool
78+ expectedTargetTransportInterrupted bool
79+}
80+
81+func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool {
82+ return token.sess == source && token.ac == req.next && token.role == req.expectedRole &&
83+ token.transport.transport == req.expectedTransport.transport &&
84+ token.transport.incarnation == req.expectedTransport.incarnation
85+}
86+
87+// transitionSourceTokenCurrentLocked requires source.mu and, for an active
88+// source, sourceCoordinator.mu. This is the canonical handoff preflight: every
89+// client-originated navigation, delete, or create intent is bound to one exact
90+// role generation, transport incarnation, and coordinator lease.
91+func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
92+ if token.sess != source || token.ac != req.next || token.role != req.expectedRole ||
93+ token.generation != req.next.roleGeneration.Load() || source.attachmentRoleLocked(req.next) != token.role ||
94+ req.next.currentSession() != source || !req.next.transportSnapshotCurrent(token.transport) {
95+ return false
96+ }
97+ if token.role != attachmentActive {
98+ return true
99+ }
100+ return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next &&
101+ sourceCoordinator.leaseCurrentLocked(token.lease, true)
102+}
103+
104+type attachmentRoleToken struct {
105+ sess *session
106+ ac *attachedClient
107+ role attachmentRole
108+ generation uint64
109+ transport transportSnapshot
110+ lease *attachmentLease
111+ effect *roleEffectTicket
112+ // rebase marks a cross-session ownership move whose first paint must start a
113+ // dependency-free output chain. The rebase is deliberately deferred until
114+ // after transition publication releases all architecture locks.
115+ rebase bool
116+}
117+
118+func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken {
119+ if s == nil || ac == nil || tr == nil {
120+ return attachmentRoleToken{}
121+ }
122+ s.mu.Lock()
123+ transport := ac.transportSnapshot()
124+ if transport.transport != tr {
125+ s.mu.Unlock()
126+ return attachmentRoleToken{}
127+ }
128+ token := attachmentRoleToken{
129+ sess: s,
130+ ac: ac,
131+ role: s.attachmentRoleLocked(ac),
132+ generation: ac.roleGeneration.Load(),
133+ transport: transport,
134+ }
135+ s.mu.Unlock()
136+ if token.role == attachmentActive {
137+ if rc := s.renderCoordinator(); rc != nil {
138+ token.lease = rc.attachmentLease(ac)
139+ }
140+ }
141+ ac.bootstrapRoleCapability(token)
142+ return token
143+}
144+
145+func (t attachmentRoleToken) current() bool {
146+ return t.sess != nil && t.ac != nil &&
147+ t.ac.roleGeneration.Load() == t.generation &&
148+ t.ac.transportSnapshotCurrent(t.transport) &&
149+ t.sess.attachmentRole(t.ac) == t.role
150+}
151+
152+// activeCurrent admits post-transition effects only for the exact published
153+// role, transport incarnation, current-session link, and coordinator lease.
154+func (t attachmentRoleToken) activeCurrent() bool {
155+ if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentSession() != t.sess {
156+ return false
157+ }
158+ rc := t.sess.renderCoordinator()
159+ return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
160+}
161+
162+// activeEffect is the canonical admission check for a client-frame mutation.
163+// Tokens bind role generation, transport incarnation, session ownership, and
164+// coordinator lease, so handlers never rediscover authority from mutable state.
165+func (t attachmentRoleToken) activeEffect() bool {
166+ if t.effect != nil && !t.effect.ended.Load() {
167+ return true
168+ }
169+ return t.activeCurrent()
170+}
171+
172+func beginActiveLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) {
173+ if sess == nil || ac == nil || lease == nil {
174+ return nil, false
175+ }
176+ token := sess.attachmentToken(ac, ac.transport())
177+ token.lease = lease
178+ return ac.beginRoleEffect(token)
179+}
180+
181+func (t *attachmentRoleToken) endRoleEffect() {
182+ if t == nil || t.effect == nil {
183+ return
184+ }
185+ t.effect.End()
186+ t.effect = nil
187+}
188+
189+// activeEffectSessionLocked is the same admission check while t.sess.mu is
190+// already held at a session-owned mutation boundary.
191+func (t attachmentRoleToken) activeEffectSessionLocked() bool {
192+ if t.effect != nil && !t.effect.ended.Load() {
193+ return true
194+ }
195+ if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil ||
196+ t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) ||
197+ t.ac.currentSession() != t.sess || t.sess.attachmentRoleLocked(t.ac) != attachmentActive {
198+ return false
199+ }
200+ rc := t.sess.renderCoordinator()
201+ return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
202+}
203+
204+func (t attachmentRoleToken) sendActiveControl(frame ports.Frame) error {
205+ if t.ac == nil {
206+ return errAttachmentTransition
207+ }
208+ t.ac.sendMu.Lock()
209+ defer t.ac.sendMu.Unlock()
210+ if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) ||
211+ !t.effect.beginTransportSend(t.transport) {
212+ return errAttachmentTransition
213+ }
214+ err := t.transport.transport.Send(frame)
215+ if err != nil {
216+ t.effect.reportTransportFailure(t.transport)
217+ }
218+ t.effect.endTransportSend()
219+ return err
220+}
221+
222+type attachmentTransitionResult struct {
223+ published attachmentRoleToken
224+ displaced attachmentRoleToken
225+ displacedInterrupted bool
226+ cleanups []renderLifecycleCleanup
227+}
228+
229+func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
230+ // Preflight identifies every gate the publication can affect. No gate is
231+ // frozen while an architecture lock is held.
232+ d.mu.Lock()
233+ source := req.source
234+ if source == nil {
235+ source = req.target
236+ }
237+ if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing ||
238+ d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target {
239+ d.mu.Unlock()
240+ return attachmentTransitionResult{}, errAttachmentTransition
241+ }
242+ if req.target != nil {
243+ req.target.mu.Lock()
244+ req.expectedTargetCurrent = req.target.client
245+ if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive {
246+ req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot()
247+ }
248+ req.target.mu.Unlock()
249+ }
250+ if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
251+ d.mu.Unlock()
252+ return attachmentTransitionResult{}, errAttachmentTransition
253+ }
254+ req.preflighted = true
255+ d.mu.Unlock()
256+
257+ if req.sourceToken != nil {
258+ d.endActionRoleEffect(req.sourceToken.effect, req.action)
259+ }
260+
261+ participants := []*attachedClient{req.next, req.expectedTargetCurrent}
262+ if d.afterRoleEffectParticipantsSnapshotted != nil {
263+ d.afterRoleEffectParticipantsSnapshotted(req.action, participants)
264+ }
265+ interrupts := []roleTransportInterrupt{{
266+ ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport,
267+ }}
268+ frozen := freezeRoleEffectGatesInterruptingObserved(interrupts, func(ac *attachedClient) {
269+ if d.afterRoleEffectGateFrozen != nil {
270+ d.afterRoleEffectGateFrozen(req.action, ac)
271+ }
272+ }, participants...)
273+ defer frozen.unfreeze()
274+ req.expectedTargetTransportInterrupted = frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport)
275+ if d.afterRoleEffectsFrozen != nil {
276+ d.afterRoleEffectsFrozen()
277+ }
278+
279+ d.mu.Lock()
280+ req.roleEffectsFrozen = true
281+ if req.target == nil {
282+ if !d.transitionSourcePreflightLocked(req) {
283+ d.mu.Unlock()
284+ return attachmentTransitionResult{}, errAttachmentTransition
285+ }
286+ var err error
287+ req.target, err = req.createTargetLocked()
288+ if err != nil {
289+ d.mu.Unlock()
290+ return attachmentTransitionResult{}, err
291+ }
292+ req.expectedTargetCurrent = nil
293+ }
294+ result, err := d.transitionAttachmentLocked(req)
295+ d.mu.Unlock()
296+ return result, err
297+}
298+
299+// transitionSourcePreflightLocked validates an initiating role while its gate
300+// is frozen and d.mu is held. No caller may create or delete a target before
301+// this succeeds.
302+func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool {
303+ if req.sourceToken == nil || req.source == nil || d.sessions[req.source.id] != req.source {
304+ return false
305+ }
306+ d.notices.routingMu.Lock()
307+ defer d.notices.routingMu.Unlock()
308+ req.source.mu.Lock()
309+ defer req.source.mu.Unlock()
310+ coordinator := req.source.renderCoordinator()
311+ if coordinator == nil {
312+ return false
313+ }
314+ coordinator.mu.Lock()
315+ defer coordinator.mu.Unlock()
316+ if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) {
317+ return false
318+ }
319+ return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab)
320+}
321+
322+func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
323+ if token.sess == nil || token.ac == nil || token.role != attachmentActive {
324+ return false
325+ }
326+ d.mu.Lock()
327+ defer d.mu.Unlock()
328+ if d.closing || d.sessions[token.sess.id] != token.sess {
329+ return false
330+ }
331+ d.notices.routingMu.Lock()
332+ defer d.notices.routingMu.Unlock()
333+ token.sess.mu.Lock()
334+ defer token.sess.mu.Unlock()
335+ coordinator := token.sess.renderCoordinator()
336+ if coordinator == nil {
337+ return false
338+ }
339+ coordinator.mu.Lock()
340+ defer coordinator.mu.Unlock()
341+ req := attachmentTransitionRequest{
342+ source: token.sess, next: token.ac, expectedRole: token.role,
343+ expectedTransport: token.transport,
344+ }
345+ return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
346+}
347+
348+func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
349+ if expected == nil {
350+ return true
351+ }
352+ if source.active < 0 || source.active >= len(source.tabs) || source.tabs[source.active] != expected {
353+ return false
354+ }
355+ expected.mu.Lock()
356+ defer expected.mu.Unlock()
357+ return expected.floating.state != floatingVisible
358+}
359+
360+// transitionAttachmentLocked is the publication half of transitionAttachment.
361+// Caller holds d.mu after the lock-free gate freeze/drain has completed.
362+func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
363+ d.notices.routingMu.Lock()
364+ defer d.notices.routingMu.Unlock()
365+ return d.transitionAttachmentRoutedLocked(req)
366+}
367+
368+// transitionAttachmentRoutedLocked performs the centralized role publication.
369+// Caller holds d.mu and notices.routingMu. Every fallible lifecycle, role, and
370+// transport check occurs before session.client is changed. Coordinator
371+// finalization after that commit point cannot roll ownership back.
372+func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
373+ source := req.source
374+ if source == nil {
375+ source = req.target
376+ }
377+ if !req.preflighted || !req.roleEffectsFrozen ||
378+ !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||
379+ source == nil || req.target == nil || req.next == nil || d.closing ||
380+ d.sessions[source.id] != source || d.sessions[req.target.id] != req.target ||
381+ req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) {
382+ return attachmentTransitionResult{}, errAttachmentTransition
383+ }
384+ // Install the target coordinator object before taking both session locks;
385+ // lease binding itself remains part of the atomic publication below.
386+ var targetCoordinator *renderCoordinator
387+ if req.targetRole == attachmentActive {
388+ targetCoordinator = d.ensureRenderCoordinator(req.target)
389+ }
390+ sourceCoordinator := source.renderCoordinator()
391+
392+ unlockSessions := lockAttachmentSessions(source, req.target)
393+ if source.attachmentRoleLocked(req.next) != req.expectedRole ||
394+ !req.next.transportSnapshotCurrent(req.expectedTransport) {
395+ unlockSessions()
396+ return attachmentTransitionResult{}, errAttachmentTransition
397+ }
398+
399+ old := req.target.client
400+ if req.preflighted && old != req.expectedTargetCurrent {
401+ unlockSessions()
402+ return attachmentTransitionResult{}, errAttachmentTransition
403+ }
404+ if source != req.target && old == req.next {
405+ unlockSessions()
406+ return attachmentTransitionResult{}, errAttachmentTransition
407+ }
408+ unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator)
409+ if req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||
410+ !transitionSourceTabCurrentLocked(source, req.expectedSourceTab)) {
411+ unlockCoordinators()
412+ unlockSessions()
413+ return attachmentTransitionResult{}, errAttachmentTransition
414+ }
415+ if targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) {
416+ unlockCoordinators()
417+ unlockSessions()
418+ return attachmentTransitionResult{}, errAttachmentTransition
419+ }
420+ if req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
421+ (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) {
422+ unlockCoordinators()
423+ unlockSessions()
424+ return attachmentTransitionResult{}, errAttachmentTransition
425+ }
426+ if req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs)) {
427+ unlockCoordinators()
428+ unlockSessions()
429+ return attachmentTransitionResult{}, errAttachmentTransition
430+ }
431+
432+ if req.copySourceEnvironment {
433+ req.target.terminal = source.terminal
434+ req.target.env = copyEnvironment(source.env)
435+ }
436+ if req.activateTargetTab {
437+ req.target.active = req.targetTabIndex
438+ }
439+ if req.preserveRole {
440+ unlockCoordinators()
441+ unlockSessions()
442+ return attachmentTransitionResult{published: *req.sourceToken}, nil
443+ }
444+
445+ var displacedTransport transportSnapshot
446+ if req.targetRole == attachmentActive && old != nil && old != req.next {
447+ displacedTransport = old.transportSnapshot()
448+ }
449+ if source != req.target {
450+ if source.client == req.next {
451+ source.client = nil
452+ }
453+ delete(source.snatched, req.next)
454+ }
455+ if req.targetRole == attachmentActive {
456+ delete(req.target.snatched, req.next)
457+ if old != nil && old != req.next {
458+ req.target.addSnatchedLocked(old)
459+ }
460+ req.target.client = req.next
461+ } else {
462+ req.target.addSnatchedLocked(req.next)
463+ }
464+ nextGeneration := req.next.roleGeneration.Add(1)
465+ var displacedGeneration uint64
466+ if old != nil && old != req.next && req.targetRole == attachmentActive {
467+ displacedGeneration = old.roleGeneration.Add(1)
468+ }
469+ req.next.setSession(req.target)
470+
471+ result := attachmentTransitionResult{}
472+ if source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil {
473+ result.cleanups = append(result.cleanups, sourceCoordinator.beginDetachLocked(req.next))
474+ }
475+ var lease *attachmentLease
476+ if targetCoordinator != nil {
477+ var cleanup renderLifecycleCleanup
478+ cleanup, lease = targetCoordinator.beginReplaceLocked(old, req.next, req.ready)
479+ result.cleanups = append(result.cleanups, cleanup)
480+ }
481+ result.published = attachmentRoleToken{
482+ sess: req.target,
483+ ac: req.next,
484+ role: req.targetRole,
485+ generation: nextGeneration,
486+ transport: req.expectedTransport,
487+ lease: lease,
488+ rebase: source != req.target && req.expectedRole == attachmentActive,
489+ }
490+ if displacedGeneration != 0 {
491+ result.displaced = attachmentRoleToken{
492+ sess: req.target,
493+ ac: old,
494+ role: attachmentSnatched,
495+ generation: displacedGeneration,
496+ transport: displacedTransport,
497+ }
498+ result.displacedInterrupted = req.expectedTargetTransportInterrupted
499+ }
500+ // Membership, currentSession, lease, generation, and both exact admission
501+ // capabilities become visible as one frozen publication. The gates remain
502+ // frozen until every architecture lock below has been released.
503+ if req.roleEffectsFrozen {
504+ req.next.publishFrozenRoleCapability(result.published)
505+ if result.displaced.ac != nil {
506+ result.displaced.ac.publishFrozenRoleCapability(result.displaced)
507+ }
508+ }
509+ unlockCoordinators()
510+ unlockSessions()
511+ return result, nil
512+}
513+
514+func (d *Daemon) clearCaptureFramesForSnatch(token attachmentRoleToken) bool {
515+ ac := token.ac
516+ ac.sendMu.Lock()
517+ defer ac.sendMu.Unlock()
518+ if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
519+ return false
520+ }
521+ ac.captureFrames = nil
522+ return true
523+}
524+
525+func (d *Daemon) sendSnatchedControl(token attachmentRoleToken, frame ports.Frame) error {
526+ ac := token.ac
527+ ac.sendMu.Lock()
528+ defer ac.sendMu.Unlock()
529+ if ac.roleGeneration.Load() != token.generation {
530+ return errSnatchedOutputStale
531+ }
532+ if !ac.transportSnapshotCurrent(token.transport) {
533+ return errTransportReplaced
534+ }
535+ if token.effect == nil || !token.effect.beginTransportSend(token.transport) {
536+ return errAttachmentTransition
537+ }
538+ err := token.transport.transport.Send(frame)
539+ if err != nil {
540+ token.effect.reportTransportFailure(token.transport)
541+ }
542+ token.effect.endTransportSend()
543+ return err
544+}
545+
546+// removeSnatchedAttachment removes exactly one role generation and transport
547+// from session routing. It never touches the active attachment or closes a
548+// transport, allowing callers that already interrupted a blocked send to avoid
549+// closing the same link twice.
550+func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool {
551+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
552+ return false
553+ }
554+ frozen := freezeRoleEffectGates(token.ac)
555+ defer frozen.unfreeze()
556+ d.notices.routingMu.Lock()
557+ token.sess.mu.Lock()
558+ current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
559+ token.ac.roleGeneration.Load() == token.generation &&
560+ token.ac.transportSnapshotCurrent(token.transport)
561+ if current {
562+ delete(token.sess.snatched, token.ac)
563+ token.ac.roleGeneration.Add(1)
564+ token.ac.invalidateFrozenRoleCapability()
565+ }
566+ token.sess.mu.Unlock()
567+ d.notices.routingMu.Unlock()
568+ if !current {
569+ return false
570+ }
571+
572+ d.unregisterPreview(token.ac)
573+ token.ac.clearPreviousSession()
574+ token.ac.setSession(nil)
575+ token.ac.clearCaptureFrames()
576+ return true
577+}
578+
579+// cleanupInterruptedSnatchedAttachment owns terminal cleanup for a displaced
580+// transport that was closed to drain an admitted send. It waits on the gate's
581+// condition rather than ordinary effect admission, then removes only the exact
582+// published snatched incarnation. Attachment-local cleanup runs without routing
583+// or session locks while the terminal capability remains frozen.
584+func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool {
585+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
586+ return false
587+ }
588+ frozen := freezeRoleEffectGates(token.ac)
589+ defer frozen.unfreeze()
590+
591+ d.notices.routingMu.Lock()
592+ token.sess.mu.Lock()
593+ current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
594+ token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
595+ token.ac.transportSnapshotCurrent(token.transport)
596+ token.sess.mu.Unlock()
597+ d.notices.routingMu.Unlock()
598+ if !current {
599+ return false
600+ }
601+
602+ // The frozen gate prevents a promotion from publishing between overlay
603+ // cleanup and terminal registry publication.
604+ d.clearForSnatch(token)
605+
606+ d.notices.routingMu.Lock()
607+ token.sess.mu.Lock()
608+ current = token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
609+ token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
610+ token.ac.transportSnapshotCurrent(token.transport)
611+ if current {
612+ delete(token.sess.snatched, token.ac)
613+ token.ac.roleGeneration.Add(1)
614+ token.ac.invalidateFrozenRoleCapability()
615+ }
616+ token.sess.mu.Unlock()
617+ d.notices.routingMu.Unlock()
618+ if !current {
619+ return false
620+ }
621+
622+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
623+ d.unregisterPreview(token.ac)
624+ token.ac.clearPreviousSession()
625+ token.ac.setSession(nil)
626+ token.ac.clearCaptureFrames()
627+ return true
628+}
629+
630+// dropSnatchedAttachment removes and closes only the captured snatched link.
631+func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool {
632+ if !d.removeSnatchedAttachment(token) {
633+ return false
634+ }
635+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
636+ return true
637+}
638+
639+func validAttachmentTransitionRole(role attachmentRole, expected bool) bool {
640+ if role == attachmentActive || role == attachmentSnatched {
641+ return true
642+ }
643+ return expected && role == attachmentDetached
644+}
645+
646+// lockAttachmentSessions gives every two-session transition one stable order.
647+// Session IDs are immutable and unique while their lifecycles are registered.
648+func lockAttachmentSessions(a, b *session) func() {
649+ if a == b {
650+ a.mu.Lock()
651+ return a.mu.Unlock
652+ }
653+ first, second := a, b
654+ if first.id > second.id {
655+ first, second = second, first
656+ }
657+ first.mu.Lock()
658+ second.mu.Lock()
659+ return func() {
660+ second.mu.Unlock()
661+ first.mu.Unlock()
662+ }
663+}
664+
665+// lockAttachmentCoordinators follows the same immutable session-ID order as
666+// lockAttachmentSessions. Callers already hold the ordered session locks.
667+func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() {
668+ if aCoordinator == nil && bCoordinator == nil {
669+ return func() {}
670+ }
671+ if a == b || aCoordinator == bCoordinator {
672+ coordinator := aCoordinator
673+ if coordinator == nil {
674+ coordinator = bCoordinator
675+ }
676+ coordinator.mu.Lock()
677+ return coordinator.mu.Unlock
678+ }
679+ first, second := aCoordinator, bCoordinator
680+ if a.id > b.id {
681+ first, second = second, first
682+ }
683+ if first != nil {
684+ first.mu.Lock()
685+ }
686+ if second != nil {
687+ second.mu.Lock()
688+ }
689+ return func() {
690+ if second != nil {
691+ second.mu.Unlock()
692+ }
693+ if first != nil {
694+ first.mu.Unlock()
695+ }
696+ }
697+}
698+
699+// deferAttachmentTransitionCleanups retires coordinator timers and accounting
700+// after ownership publication. Cleanup never gates the attaching handshake. A
701+// displaced render that already owns sendMu is interrupted by closing only its
702+// captured transport; an idle healthy snatched transport remains connected and
703+// receives a dependency-free reset panel.
704+func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) {
705+ if token := result.displaced; token.ac != nil && token.transport.transport != nil {
706+ blockedRender := result.displacedInterrupted
707+ if !blockedRender {
708+ blockedRender = !token.ac.sendMu.TryLock()
709+ if !blockedRender {
710+ token.ac.sendMu.Unlock()
711+ }
712+ }
713+ d.attachmentCleanupWg.Go(func() {
714+ if d.afterDisplacedCleanupStarted != nil {
715+ d.afterDisplacedCleanupStarted()
716+ }
717+ if blockedRender {
718+ // This path owns terminal cleanup independently of ordinary role
719+ // admission: a stale send-error detach may transiently freeze the gate.
720+ _ = token.ac.closeCapturedTransport(token.transport.transport)
721+ d.cleanupInterruptedSnatchedAttachment(token)
722+ return
723+ }
724+ ticket, admitted := token.ac.beginRoleEffect(token)
725+ if !admitted {
726+ return
727+ }
728+ defer ticket.End()
729+ if !token.current() || !d.clearForSnatch(token) || !d.clearCaptureFramesForSnatch(token) {
730+ return
731+ }
732+ if err := d.sendSnatchedPanel(token.ac, token.transport, token.generation, "", ticket); err != nil {
733+ ticket.End()
734+ d.dropSnatchedAttachment(token)
735+ }
736+ })
737+ }
738+ for _, cleanup := range result.cleanups {
739+ d.attachmentCleanupWg.Go(cleanup.finish)
740+ }
741+}
A internal/usecase/daemon/snatched_client_test.go +960 -0
@@ -0,0 +1,960 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/keys"
16+ themeui "github.com/bnema/vev/internal/usecase/theme"
17+ "github.com/bnema/vev/pkg/renderer"
18+)
19+
20+func TestSessionAttachmentRole(t *testing.T) {
21+ active := &attachedClient{}
22+ waiting := &attachedClient{}
23+ unknown := &attachedClient{}
24+ sess := &session{client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}
25+
26+ require.Equal(t, attachmentActive, sess.attachmentRole(active))
27+ require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
28+ require.Equal(t, attachmentDetached, sess.attachmentRole(unknown))
29+}
30+
31+func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) {
32+ d := newTestDaemon(t, nil, stubClock{})
33+ sess := &session{id: domain.SessionID("work")}
34+ oldTransport := &closeTrackingTransport{}
35+ old := &attachedClient{tr: oldTransport}
36+ old.setSession(sess)
37+ sess.client = old
38+ d.sessions[sess.id] = sess
39+
40+ newTransport := &closeTrackingTransport{}
41+ next := &attachedClient{tr: newTransport}
42+ expected := next.transportSnapshot()
43+ result, err := d.transitionAttachment(attachmentTransitionRequest{
44+ target: sess,
45+ next: next,
46+ expectedRole: attachmentDetached,
47+ targetRole: attachmentActive,
48+ expectedTransport: expected,
49+ })
50+ require.NoError(t, err)
51+
52+ sess.mu.Lock()
53+ require.Same(t, next, sess.client)
54+ require.Contains(t, sess.snatched, old)
55+ _, nextSnatched := sess.snatched[next]
56+ require.False(t, nextSnatched)
57+ sess.mu.Unlock()
58+ require.Equal(t, attachmentActive, sess.attachmentRole(next))
59+ require.Equal(t, attachmentSnatched, sess.attachmentRole(old))
60+ require.True(t, result.published.current())
61+ require.True(t, result.displaced.current())
62+ require.Equal(t, uint64(1), result.published.generation)
63+ require.Equal(t, uint64(1), result.displaced.generation)
64+ require.Same(t, sess, next.currentSession())
65+ require.Same(t, sess, old.currentSession())
66+}
67+
68+func TestDeferredAttachmentCleanupKeepsReplacementOpenAndSendsResetPanel(t *testing.T) {
69+ d := newTestDaemon(t, nil, stubClock{})
70+ sess := &session{id: domain.SessionID("work")}
71+ oldTransport := &closeTrackingTransport{}
72+ old := &attachedClient{
73+ tr: oldTransport,
74+ output: newOutputStateStream(),
75+ size: domain.Size{Cols: 80, Rows: 24},
76+ }
77+ old.initOverlays()
78+ old.setThemeForTest(themeui.Theme{})
79+ old.setSession(sess)
80+ sess.client = old
81+ d.sessions[sess.id] = sess
82+
83+ next := &attachedClient{tr: &closeTrackingTransport{}}
84+ result, err := d.transitionAttachment(attachmentTransitionRequest{
85+ target: sess,
86+ next: next,
87+ expectedRole: attachmentDetached,
88+ targetRole: attachmentActive,
89+ expectedTransport: next.transportSnapshot(),
90+ })
91+ require.NoError(t, err)
92+
93+ d.deferAttachmentTransitionCleanups(result)
94+ d.attachmentCleanupWg.Wait()
95+
96+ require.False(t, oldTransport.Closed(), "snatched transport must stay open")
97+ frames := oldTransport.Sends()
98+ require.Len(t, frames, 1)
99+ require.Equal(t, ports.MsgOutput, frames[0].Type)
100+ output, err := ports.UnmarshalOutput(frames[0].Payload)
101+ require.NoError(t, err)
102+ require.Contains(t, string(output.Data), "Session snatched")
103+}
104+
105+func TestActiveFrameRevalidatesExactRoleBeforeEveryEffect(t *testing.T) {
106+ tests := []struct {
107+ name string
108+ setup func(*attachedClient, *session, *transactionalResizePTY)
109+ frame func() ports.Frame
110+ assert func(*testing.T, *Daemon, *session, *attachedClient, *attachedClient, *transactionalResizePTY, *datagramTestTransport)
111+ }{
112+ {
113+ name: "forwarded input",
114+ frame: func() ports.Frame { return frameInput([]byte("stale")) },
115+ assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) {
116+ require.Empty(t, pty.writes(), "stale input reached the PTY")
117+ },
118+ },
119+ {
120+ name: "key action",
121+ frame: func() ports.Frame { return frameInput([]byte("\x1b ")) },
122+ assert: func(t *testing.T, _ *Daemon, _ *session, old, next *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) {
123+ require.False(t, old.overlays.paletteActive(), "stale key action mutated the displaced overlay")
124+ require.False(t, next.overlays.paletteActive(), "stale key action mutated the active overlay")
125+ },
126+ },
127+ {
128+ name: "mouse input",
129+ setup: func(_ *attachedClient, sess *session, _ *transactionalResizePTY) {
130+ pane := sess.activeTab().focusedPane()
131+ pane.mu.Lock()
132+ pane.screen.Write([]byte("\x1b[?1000h"))
133+ pane.mu.Unlock()
134+ },
135+ frame: func() ports.Frame { return frameInput([]byte("\x1b[<0;1;2M")) },
136+ assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) {
137+ require.Empty(t, pty.writes(), "stale mouse input reached the PTY")
138+ },
139+ },
140+ {
141+ name: "image push",
142+ frame: func() ports.Frame {
143+ return ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: "image/png", Data: []byte("stale-image")})}
144+ },
145+ assert: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) {
146+ sess.mu.Lock()
147+ files := append([]string(nil), sess.clipFiles...)
148+ sess.mu.Unlock()
149+ require.Empty(t, files, "stale image push created a session clipboard file")
150+ require.Empty(t, pty.writes(), "stale image path reached the PTY")
151+ },
152+ },
153+ {
154+ name: "resize",
155+ frame: func() ports.Frame {
156+ return ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 120, Rows: 40}})}
157+ },
158+ assert: func(t *testing.T, _ *Daemon, sess *session, old, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) {
159+ require.Empty(t, pty.requested(), "stale resize reached a session PTY")
160+ require.Nil(t, sess.renderCoordinator().attachmentLease(old), "stale resize rebound the coordinator to the displaced client")
161+ },
162+ },
163+ {
164+ name: "theme",
165+ frame: func() ports.Frame {
166+ return ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{
167+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
168+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
169+ })}
170+ },
171+ assert: func(t *testing.T, _ *Daemon, _ *session, old, _ *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) {
172+ require.False(t, old.getClientTheme().HasFG, "stale theme mutated the displaced client")
173+ },
174+ },
175+ {
176+ name: "client notice",
177+ frame: func() ports.Frame {
178+ return ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback})}
179+ },
180+ assert: func(t *testing.T, d *Daemon, _ *session, old, next *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) {
181+ require.Empty(t, d.notices.history(), "stale notice mutated daemon history")
182+ oldToasts, _ := visibleToasts(old)
183+ nextToasts, _ := visibleToasts(next)
184+ require.Empty(t, oldToasts)
185+ require.Empty(t, nextToasts)
186+ },
187+ },
188+ {
189+ name: "ack",
190+ setup: func(old *attachedClient, _ *session, _ *transactionalResizePTY) {
191+ old.output.next = 2
192+ },
193+ frame: func() ports.Frame {
194+ return ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})}
195+ },
196+ assert: func(t *testing.T, _ *Daemon, _ *session, old, _ *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) {
197+ require.Equal(t, uint64(2), old.output.outstanding(), "stale ack retired displaced output state")
198+ },
199+ },
200+ {
201+ name: "ping",
202+ frame: func() ports.Frame { return ports.Frame{Type: ports.MsgPing} },
203+ assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, _ *transactionalResizePTY, tr *datagramTestTransport) {
204+ select {
205+ case frame := <-tr.sends:
206+ t.Fatalf("stale ping wrote frame type %d", frame.Type)
207+ default:
208+ }
209+ },
210+ },
211+ }
212+
213+ for _, tt := range tests {
214+ t.Run(tt.name, func(t *testing.T) {
215+ pty := &transactionalResizePTY{}
216+ d, sess, old, _ := newManualSessionWithPTYs(t, pty)
217+ d.tempDir = t.TempDir()
218+ oldTransport := newDatagramTestTransport()
219+ old.replaceTransport(oldTransport)
220+ old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
221+ rc := d.attachCoordinator(sess, nil, old, true)
222+ token := sess.attachmentToken(old, oldTransport)
223+ token.lease = rc.attachmentLease(old)
224+ require.True(t, token.activeCurrent())
225+ if tt.setup != nil {
226+ tt.setup(old, sess, pty)
227+ }
228+
229+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
230+ next.initOverlays()
231+ var transition attachmentTransitionResult
232+ d.afterActiveFrameDispatch = func(attachmentRoleToken) {
233+ d.afterActiveFrameDispatch = nil
234+ var err error
235+ transition, err = d.transitionAttachment(attachmentTransitionRequest{
236+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
237+ expectedTransport: next.transportSnapshot(), ready: true,
238+ })
239+ require.NoError(t, err)
240+ }
241+
242+ d.handleActiveClientFrame(token, tt.frame())
243+ for _, cleanup := range transition.cleanups {
244+ cleanup.finish()
245+ }
246+ tt.assert(t, d, sess, old, next, pty, oldTransport)
247+ })
248+ }
249+}
250+
251+func TestKillSessionRetiresEverySnatchedConnectionWithoutCrossSessionEffects(t *testing.T) {
252+ pty := &transactionalResizePTY{}
253+ d, sess, first, _ := newManualSessionWithPTYs(t, pty)
254+ firstTransport := newDatagramTestTransport()
255+ first.replaceTransport(firstTransport)
256+
257+ secondTransport := newDatagramTestTransport()
258+ second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size}
259+ second.initOverlays()
260+ firstReplacement, err := d.transitionAttachment(attachmentTransitionRequest{
261+ target: sess, next: second, expectedRole: attachmentDetached, targetRole: attachmentActive,
262+ expectedTransport: second.transportSnapshot(), ready: true,
263+ })
264+ require.NoError(t, err)
265+ for _, cleanup := range firstReplacement.cleanups {
266+ cleanup.finish()
267+ }
268+
269+ thirdTransport := newDatagramTestTransport()
270+ third := &attachedClient{tr: thirdTransport, output: newOutputStateStream(), size: first.size}
271+ third.initOverlays()
272+ secondReplacement, err := d.transitionAttachment(attachmentTransitionRequest{
273+ target: sess, next: third, expectedRole: attachmentDetached, targetRole: attachmentActive,
274+ expectedTransport: third.transportSnapshot(), ready: true,
275+ })
276+ require.NoError(t, err)
277+ for _, cleanup := range secondReplacement.cleanups {
278+ cleanup.finish()
279+ }
280+
281+ otherCtx, otherCancel := context.WithCancel(d.serveCtx)
282+ defer otherCancel()
283+ otherTransport := newDatagramTestTransport()
284+ otherClient := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: first.size}
285+ otherClient.initOverlays()
286+ other := &session{id: "other", name: "other", ctx: otherCtx, cancel: otherCancel, client: otherClient}
287+ otherClient.setSession(other)
288+ d.mu.Lock()
289+ d.sessions[other.id] = other
290+ d.mu.Unlock()
291+ d.attachCoordinator(other, nil, otherClient, true)
292+
293+ roles := []*attachedClient{first, second, third}
294+ generations := make([]uint64, len(roles))
295+ for i, ac := range roles {
296+ generations[i] = ac.roleGeneration.Load()
297+ }
298+ var killedLoops sync.WaitGroup
299+ for _, ac := range roles {
300+ killedLoops.Go(func() { d.runConnLoop(ac) })
301+ }
302+ otherDone := make(chan struct{})
303+ go func() {
304+ d.runConnLoop(otherClient)
305+ close(otherDone)
306+ }()
307+ defer func() {
308+ _ = firstTransport.Close()
309+ _ = secondTransport.Close()
310+ _ = thirdTransport.Close()
311+ _ = otherTransport.Close()
312+ killedLoops.Wait()
313+ <-otherDone
314+ }()
315+
316+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
317+ d.waitNotifies()
318+ select {
319+ case <-waitGroupDone(&killedLoops):
320+ case <-time.After(2 * time.Second):
321+ t.Fatal("killed active and snatched connection loops did not retire")
322+ }
323+
324+ sess.mu.Lock()
325+ require.Nil(t, sess.client)
326+ require.Empty(t, sess.snatched)
327+ sess.mu.Unlock()
328+ for i, ac := range roles {
329+ require.Nil(t, ac.currentSession())
330+ require.Greater(t, ac.roleGeneration.Load(), generations[i], "session kill did not invalidate role generation")
331+ }
332+ for _, transport := range []*datagramTestTransport{firstTransport, secondTransport, thirdTransport} {
333+ detached := awaitFrame(t, transport.sends, ports.MsgDetached)
334+ message, err := ports.UnmarshalDetached(detached.Payload)
335+ require.NoError(t, err)
336+ require.Equal(t, ports.ReasonSessionKilled, message.Reason)
337+ }
338+ require.Same(t, otherClient, other.client)
339+ require.Same(t, other, otherClient.currentSession())
340+ require.Equal(t, attachmentActive, other.attachmentRole(otherClient))
341+
342+ otherTransport.recv <- ports.Frame{Type: ports.MsgPing}
343+ require.Equal(t, ports.MsgPong, awaitFrame(t, otherTransport.sends, ports.MsgPong).Type)
344+ select {
345+ case <-otherDone:
346+ t.Fatal("killing another session retired the active client's connection")
347+ default:
348+ }
349+}
350+
351+func TestSnatchedConnectionInputCannotReachPTY(t *testing.T) {
352+ writes := make(chan []byte, 1)
353+ pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}
354+ d, sess, old, _ := newManualSessionWithPTYs(t, pty)
355+ oldTransport := newDatagramTestTransport()
356+ old.replaceTransport(oldTransport)
357+
358+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
359+ next.initOverlays()
360+ result, err := d.transitionAttachment(attachmentTransitionRequest{
361+ target: sess,
362+ next: next,
363+ expectedRole: attachmentDetached,
364+ targetRole: attachmentActive,
365+ expectedTransport: next.transportSnapshot(),
366+ })
367+ require.NoError(t, err)
368+ d.deferAttachmentTransitionCleanups(result)
369+ d.attachmentCleanupWg.Wait()
370+ awaitFrame(t, oldTransport.sends, ports.MsgOutput)
371+
372+ oldTransport.recv <- frameInput([]byte("restricted"))
373+ require.NoError(t, oldTransport.Close())
374+ d.runConnLoop(old)
375+
376+ select {
377+ case got := <-writes:
378+ t.Fatalf("snatched input reached PTY: %q", got)
379+ default:
380+ }
381+}
382+
383+func TestStaleActiveKeyForwardCannotReachPTYAfterSnatch(t *testing.T) {
384+ writes := make(chan []byte, 1)
385+ pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}
386+ d, sess, old, _ := newManualSessionWithPTYs(t, pty)
387+ old.replaceTransport(&closeTrackingTransport{})
388+
389+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
390+ next.initOverlays()
391+ _, err := d.transitionAttachment(attachmentTransitionRequest{
392+ target: sess,
393+ next: next,
394+ expectedRole: attachmentDetached,
395+ targetRole: attachmentActive,
396+ expectedTransport: next.transportSnapshot(),
397+ })
398+ require.NoError(t, err)
399+
400+ (daemonKeyHandler{d: d, ac: old}).Forward([]byte("stale"))
401+
402+ select {
403+ case got := <-writes:
404+ t.Fatalf("stale active key forward reached PTY: %q", got)
405+ default:
406+ }
407+}
408+
409+func TestSnatchedConnectionResizeUpdatesPanelWithoutResizingSession(t *testing.T) {
410+ pty := &transactionalResizePTY{}
411+ d, sess, old, _ := newManualSessionWithPTYs(t, pty)
412+ oldTransport := newDatagramTestTransport()
413+ old.replaceTransport(oldTransport)
414+
415+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
416+ next.initOverlays()
417+ result, err := d.transitionAttachment(attachmentTransitionRequest{
418+ target: sess,
419+ next: next,
420+ expectedRole: attachmentDetached,
421+ targetRole: attachmentActive,
422+ expectedTransport: next.transportSnapshot(),
423+ })
424+ require.NoError(t, err)
425+ d.deferAttachmentTransitionCleanups(result)
426+ d.attachmentCleanupWg.Wait()
427+ awaitFrame(t, oldTransport.sends, ports.MsgOutput)
428+
429+ want := domain.Size{Cols: 100, Rows: 30}
430+ oldTransport.recv <- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: want})}
431+ require.NoError(t, oldTransport.Close())
432+ d.runConnLoop(old)
433+
434+ require.Equal(t, want, old.size)
435+ require.Empty(t, pty.requested(), "snatched resize must not resize session PTYs")
436+ panel := awaitFrame(t, oldTransport.sends, ports.MsgOutput)
437+ output, err := ports.UnmarshalOutput(panel.Payload)
438+ require.NoError(t, err)
439+ require.Contains(t, string(output.Data), "Session snatched")
440+}
441+
442+func TestSnatchedConnectionAcknowledgesPanelAndAnswersPing(t *testing.T) {
443+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
444+ oldTransport := newDatagramTestTransport()
445+ old.replaceTransport(oldTransport)
446+
447+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
448+ next.initOverlays()
449+ result, err := d.transitionAttachment(attachmentTransitionRequest{
450+ target: sess,
451+ next: next,
452+ expectedRole: attachmentDetached,
453+ targetRole: attachmentActive,
454+ expectedTransport: next.transportSnapshot(),
455+ })
456+ require.NoError(t, err)
457+ d.deferAttachmentTransitionCleanups(result)
458+ d.attachmentCleanupWg.Wait()
459+ panelFrame := awaitFrame(t, oldTransport.sends, ports.MsgOutput)
460+ panel, err := ports.UnmarshalOutput(panelFrame.Payload)
461+ require.NoError(t, err)
462+ require.Equal(t, uint64(1), old.output.outstanding())
463+
464+ oldTransport.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: panel.NewStateNum})}
465+ oldTransport.recv <- ports.Frame{Type: ports.MsgPing}
466+ require.NoError(t, oldTransport.Close())
467+ d.runConnLoop(old)
468+
469+ require.Zero(t, old.output.outstanding())
470+ require.Equal(t, ports.MsgPong, awaitFrame(t, oldTransport.sends, ports.MsgPong).Type)
471+}
472+
473+func TestSnatchedConnectionThemeRepaintsPanelWithoutRecoloringSession(t *testing.T) {
474+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
475+ oldTransport := newDatagramTestTransport()
476+ old.replaceTransport(oldTransport)
477+
478+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
479+ next.initOverlays()
480+ result, err := d.transitionAttachment(attachmentTransitionRequest{
481+ target: sess,
482+ next: next,
483+ expectedRole: attachmentDetached,
484+ targetRole: attachmentActive,
485+ expectedTransport: next.transportSnapshot(),
486+ })
487+ require.NoError(t, err)
488+ d.deferAttachmentTransitionCleanups(result)
489+ d.attachmentCleanupWg.Wait()
490+ awaitFrame(t, oldTransport.sends, ports.MsgOutput)
491+
492+ activeTheme := ports.Theme{
493+ HasForeground: true, Foreground: renderer.RGB{R: 10, G: 20, B: 30},
494+ HasBackground: true, Background: renderer.RGB{R: 40, G: 50, B: 60},
495+ TrueColor: true,
496+ }
497+ d.applyTheme(sess, next, activeTheme)
498+ snatchedTheme := ports.Theme{
499+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
500+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
501+ TrueColor: true,
502+ }
503+ oldTransport.recv <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snatchedTheme)}
504+ require.NoError(t, oldTransport.Close())
505+ d.runConnLoop(old)
506+
507+ require.Equal(t, snatchedTheme.Foreground, old.getAppliedTheme().Raw.Foreground)
508+ assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
509+ panel := awaitFrame(t, oldTransport.sends, ports.MsgOutput)
510+ output, err := ports.UnmarshalOutput(panel.Payload)
511+ require.NoError(t, err)
512+ require.Contains(t, string(output.Data), "Session snatched")
513+}
514+
515+func TestSnatchedConnectionDetachRemovesOnlySnatchedAttachment(t *testing.T) {
516+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
517+ oldTransport := newDatagramTestTransport()
518+ old.replaceTransport(oldTransport)
519+
520+ activeTransport := &closeTrackingTransport{}
521+ next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size}
522+ next.initOverlays()
523+ result, err := d.transitionAttachment(attachmentTransitionRequest{
524+ target: sess,
525+ next: next,
526+ expectedRole: attachmentDetached,
527+ targetRole: attachmentActive,
528+ expectedTransport: next.transportSnapshot(),
529+ })
530+ require.NoError(t, err)
531+ d.deferAttachmentTransitionCleanups(result)
532+ d.attachmentCleanupWg.Wait()
533+ awaitFrame(t, oldTransport.sends, ports.MsgOutput)
534+
535+ oldTransport.recv <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}
536+ d.runConnLoop(old)
537+
538+ detached := awaitFrame(t, oldTransport.sends, ports.MsgDetached)
539+ message, err := ports.UnmarshalDetached(detached.Payload)
540+ require.NoError(t, err)
541+ require.Equal(t, ports.ReasonDetach, message.Reason)
542+ require.Equal(t, attachmentDetached, sess.attachmentRole(old))
543+ require.Equal(t, attachmentActive, sess.attachmentRole(next))
544+ require.Same(t, next, sess.client)
545+ require.False(t, activeTransport.Closed())
546+}
547+
548+func TestReplacementClearsSnatchedOverlaysPreviewAndCaptures(t *testing.T) {
549+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
550+ oldTransport := &closeTrackingTransport{}
551+ old.replaceTransport(oldTransport)
552+
553+ d.enterPicker(sess, old)
554+ d.enterPalette(sess, old)
555+ d.enterPrompt(sess, old, " Rename ", "work", func(string) error { return nil })
556+ d.enterCopyMode(sess, old)
557+ d.enterNotices(sess, old)
558+ old.overlays.resizeMu.Lock()
559+ old.overlays.resizeActive = true
560+ old.overlays.resizePending = []byte("pending")
561+ old.overlays.resizeMu.Unlock()
562+ old.sendMu.Lock()
563+ old.captureFrames = map[*pane]capturedPaneRenderState{sess.activeTab().focusedPane(): {}}
564+ old.sendMu.Unlock()
565+ require.True(t, old.overlays.Active())
566+
567+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
568+ next.initOverlays()
569+ result, err := d.transitionAttachment(attachmentTransitionRequest{
570+ target: sess,
571+ next: next,
572+ expectedRole: attachmentDetached,
573+ targetRole: attachmentActive,
574+ expectedTransport: next.transportSnapshot(),
575+ })
576+ require.NoError(t, err)
577+ d.deferAttachmentTransitionCleanups(result)
578+ d.attachmentCleanupWg.Wait()
579+
580+ require.False(t, old.overlays.Active())
581+ old.overlays.pickerMu.Lock()
582+ require.Nil(t, old.overlays.pickerPreview)
583+ require.Nil(t, old.overlays.pickerPreviewSession)
584+ old.overlays.pickerMu.Unlock()
585+ old.sendMu.Lock()
586+ require.Empty(t, old.captureFrames)
587+ old.sendMu.Unlock()
588+ require.False(t, oldTransport.Closed())
589+}
590+
591+type failingSnatchedTransport struct {
592+ mu sync.Mutex
593+ closed bool
594+}
595+
596+func (*failingSnatchedTransport) Send(ports.Frame) error { return errors.New("send failed") }
597+func (*failingSnatchedTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
598+func (t *failingSnatchedTransport) Close() error {
599+ t.mu.Lock()
600+ t.closed = true
601+ t.mu.Unlock()
602+ return nil
603+}
604+func (t *failingSnatchedTransport) Closed() bool {
605+ t.mu.Lock()
606+ defer t.mu.Unlock()
607+ return t.closed
608+}
609+
610+func TestSnatchedPanelFailureRemovesOnlySnatchedAttachment(t *testing.T) {
611+ d := newTestDaemon(t, nil, stubClock{})
612+ oldTransport := &failingSnatchedTransport{}
613+ old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
614+ old.initOverlays()
615+ sess := &session{id: "work", client: old}
616+ old.setSession(sess)
617+ d.sessions[sess.id] = sess
618+
619+ activeTransport := &closeTrackingTransport{}
620+ next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size}
621+ next.initOverlays()
622+ result, err := d.transitionAttachment(attachmentTransitionRequest{
623+ target: sess,
624+ next: next,
625+ expectedRole: attachmentDetached,
626+ targetRole: attachmentActive,
627+ expectedTransport: next.transportSnapshot(),
628+ })
629+ require.NoError(t, err)
630+ d.deferAttachmentTransitionCleanups(result)
631+ d.attachmentCleanupWg.Wait()
632+
633+ require.True(t, oldTransport.Closed())
634+ require.Equal(t, attachmentDetached, sess.attachmentRole(old))
635+ require.Equal(t, attachmentActive, sess.attachmentRole(next))
636+ require.Same(t, next, sess.client)
637+ require.False(t, activeTransport.Closed())
638+}
639+
640+func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) {
641+ d := newTestDaemon(t, nil, stubClock{})
642+ oldTransport := &failingSnatchedTransport{}
643+ old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
644+ old.initOverlays()
645+ sess := &session{id: "work", client: old}
646+ old.setSession(sess)
647+ d.sessions[sess.id] = sess
648+
649+ activeTransport := &closeTrackingTransport{}
650+ next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size}
651+ next.initOverlays()
652+ result, err := d.transitionAttachment(attachmentTransitionRequest{
653+ target: sess,
654+ next: next,
655+ expectedRole: attachmentDetached,
656+ targetRole: attachmentActive,
657+ expectedTransport: next.transportSnapshot(),
658+ })
659+ require.NoError(t, err)
660+ for _, cleanup := range result.cleanups {
661+ cleanup.finish()
662+ }
663+
664+ theme := ports.Theme{
665+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
666+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
667+ TrueColor: true,
668+ }
669+ require.True(t, d.handleSnatchedClientFrame(result.displaced, ports.Frame{
670+ Type: ports.MsgTheme, Payload: ports.MarshalTheme(theme),
671+ }))
672+
673+ require.True(t, oldTransport.Closed())
674+ require.Equal(t, attachmentDetached, sess.attachmentRole(old))
675+ require.Equal(t, attachmentActive, sess.attachmentRole(next))
676+ require.Same(t, next, sess.client)
677+ require.False(t, activeTransport.Closed())
678+}
679+
680+func TestClearForSnatchReleasesEachOverlayFamilyBeforeTakingNext(t *testing.T) {
681+ d := newTestDaemon(t, nil, stubClock{})
682+ tr := &closeTrackingTransport{}
683+ ac := &attachedClient{tr: tr, output: newOutputStateStream()}
684+ ac.initOverlays()
685+ ac.roleGeneration.Store(1)
686+ sess := &session{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}
687+ ac.setSession(sess)
688+ token := attachmentRoleToken{
689+ sess: sess, ac: ac, role: attachmentSnatched,
690+ generation: 1, transport: ac.transportSnapshot(),
691+ }
692+
693+ pickerCleared := make(chan struct{})
694+ d.afterSnatchOverlayFamily = func(family string) {
695+ if family == "picker" {
696+ close(pickerCleared)
697+ }
698+ }
699+ ac.overlays.resizeMu.Lock()
700+ resizeLocked := true
701+ defer func() {
702+ if resizeLocked {
703+ ac.overlays.resizeMu.Unlock()
704+ }
705+ }()
706+ cleared := make(chan bool, 1)
707+ go func() { cleared <- d.clearForSnatch(token) }()
708+ select {
709+ case <-pickerCleared:
710+ case <-time.After(2 * time.Second):
711+ t.Fatal("picker family did not clear before blocked resize family")
712+ }
713+ require.True(t, ac.overlays.pickerMu.TryLock(), "clear retained pickerMu while waiting for resizeMu")
714+ ac.overlays.pickerMu.Unlock()
715+
716+ ac.overlays.resizeMu.Unlock()
717+ resizeLocked = false
718+ require.True(t, <-cleared)
719+}
720+
721+func TestClearForSnatchRejectsStaleGenerationWithoutClearingNewOverlay(t *testing.T) {
722+ d := newTestDaemon(t, nil, stubClock{})
723+ tr := &closeTrackingTransport{}
724+ ac := &attachedClient{tr: tr, output: newOutputStateStream()}
725+ ac.initOverlays()
726+ ac.roleGeneration.Store(1)
727+ sess := &session{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}
728+ ac.setSession(sess)
729+ token := attachmentRoleToken{
730+ sess: sess, ac: ac, role: attachmentSnatched,
731+ generation: 1, transport: ac.transportSnapshot(),
732+ }
733+
734+ ac.overlays.pickerMu.Lock()
735+ started := make(chan struct{})
736+ cleared := make(chan bool, 1)
737+ go func() {
738+ close(started)
739+ cleared <- d.clearForSnatch(token)
740+ }()
741+ <-started
742+ ac.roleGeneration.Add(1)
743+ ac.overlays.resizeMu.Lock()
744+ ac.overlays.resizeActive = true
745+ ac.overlays.resizeMu.Unlock()
746+ ac.overlays.pickerMu.Unlock()
747+
748+ require.False(t, <-cleared)
749+ require.True(t, ac.overlays.resizeModeActive(), "stale cleanup cleared a newer generation")
750+}
751+
752+func TestTransitionAttachmentRejectsBeforeOwnershipCommit(t *testing.T) {
753+ tests := []struct {
754+ name string
755+ mutate func(*Daemon, *session, *attachedClient)
756+ }{
757+ {
758+ name: "stale transport",
759+ mutate: func(_ *Daemon, _ *session, next *attachedClient) {
760+ next.replaceTransport(&closeTrackingTransport{})
761+ },
762+ },
763+ {
764+ name: "session lifecycle ended",
765+ mutate: func(d *Daemon, sess *session, _ *attachedClient) {
766+ delete(d.sessions, sess.id)
767+ },
768+ },
769+ {
770+ name: "unexpected role",
771+ mutate: func(_ *Daemon, sess *session, next *attachedClient) {
772+ sess.mu.Lock()
773+ sess.addSnatchedLocked(next)
774+ sess.mu.Unlock()
775+ },
776+ },
777+ }
778+
779+ for _, tt := range tests {
780+ t.Run(tt.name, func(t *testing.T) {
781+ d := newTestDaemon(t, nil, stubClock{})
782+ sess := &session{id: domain.SessionID("work")}
783+ old := &attachedClient{tr: &closeTrackingTransport{}}
784+ old.setSession(sess)
785+ sess.client = old
786+ d.sessions[sess.id] = sess
787+ next := &attachedClient{tr: &closeTrackingTransport{}}
788+ expected := next.transportSnapshot()
789+ tt.mutate(d, sess, next)
790+
791+ _, err := d.transitionAttachment(attachmentTransitionRequest{
792+ target: sess,
793+ next: next,
794+ expectedRole: attachmentDetached,
795+ targetRole: attachmentActive,
796+ expectedTransport: expected,
797+ })
798+ require.ErrorIs(t, err, errAttachmentTransition)
799+ require.Same(t, old, sess.client)
800+ require.Zero(t, old.roleGeneration.Load())
801+ require.Zero(t, next.roleGeneration.Load())
802+ })
803+ }
804+}
805+
806+func TestCrossSessionTransitionPublishesCoherentOwnershipBeforeBlockedFirstPaint(t *testing.T) {
807+ d := newTestDaemon(t, nil, stubClock{})
808+ from := &session{id: "z-source"}
809+ target := &session{id: "a-target"}
810+ moving := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
811+ displaced := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: moving.size}
812+ moving.initOverlays()
813+ displaced.initOverlays()
814+ moving.setSession(from)
815+ displaced.setSession(target)
816+ from.client = moving
817+ target.client = displaced
818+ d.sessions[from.id] = from
819+ d.sessions[target.id] = target
820+ sourceCoordinator := d.attachCoordinator(from, nil, moving, true)
821+ targetCoordinator := d.attachCoordinator(target, nil, displaced, true)
822+
823+ moving.sendMu.Lock()
824+ locked := true
825+ defer func() {
826+ if locked {
827+ moving.sendMu.Unlock()
828+ }
829+ }()
830+ transitionDone := make(chan attachmentTransitionResult, 1)
831+ transitionErr := make(chan error, 1)
832+ go func() {
833+ result, err := d.transitionAttachment(attachmentTransitionRequest{
834+ source: from, target: target, next: moving,
835+ expectedRole: attachmentActive, targetRole: attachmentActive,
836+ expectedTransport: moving.transportSnapshot(), ready: true,
837+ })
838+ transitionDone <- result
839+ transitionErr <- err
840+ }()
841+
842+ var result attachmentTransitionResult
843+ select {
844+ case result = <-transitionDone:
845+ require.NoError(t, <-transitionErr)
846+ case <-time.After(2 * time.Second):
847+ t.Fatal("ownership publication waited for blocked sendMu")
848+ }
849+
850+ from.mu.Lock()
851+ require.Nil(t, from.client)
852+ from.mu.Unlock()
853+ target.mu.Lock()
854+ require.Same(t, moving, target.client)
855+ require.Contains(t, target.snatched, displaced)
856+ target.mu.Unlock()
857+ require.Same(t, target, moving.currentSession())
858+ sourceCoordinator.mu.Lock()
859+ require.NotNil(t, sourceCoordinator.lease)
860+ require.False(t, sourceCoordinator.lease.active)
861+ sourceCoordinator.mu.Unlock()
862+ targetCoordinator.mu.Lock()
863+ require.Same(t, result.published.lease, targetCoordinator.lease)
864+ require.True(t, targetCoordinator.lease.active)
865+ require.Same(t, moving, targetCoordinator.lease.attachment)
866+ targetCoordinator.mu.Unlock()
867+ require.True(t, result.published.activeCurrent())
868+
869+ paintStarted := make(chan struct{})
870+ paintDone := make(chan bool, 1)
871+ go func() {
872+ close(paintStarted)
873+ paintDone <- d.firstPaintForTransition(result.published)
874+ }()
875+ <-paintStarted
876+ require.True(t, d.mu.TryLock(), "first paint retained daemon lock while waiting for sendMu")
877+ d.mu.Unlock()
878+ require.True(t, d.notices.routingMu.TryLock(), "first paint retained routing lock while waiting for sendMu")
879+ d.notices.routingMu.Unlock()
880+ require.True(t, from.mu.TryLock(), "first paint retained source session lock while waiting for sendMu")
881+ from.mu.Unlock()
882+ require.True(t, target.mu.TryLock(), "first paint retained target session lock while waiting for sendMu")
883+ target.mu.Unlock()
884+ require.True(t, sourceCoordinator.mu.TryLock(), "first paint retained source coordinator lock while waiting for sendMu")
885+ sourceCoordinator.mu.Unlock()
886+ require.True(t, targetCoordinator.mu.TryLock(), "first paint retained target coordinator lock while waiting for sendMu")
887+ targetCoordinator.mu.Unlock()
888+
889+ moving.sendMu.Unlock()
890+ locked = false
891+ require.True(t, <-paintDone)
892+}
893+
894+func TestFirstPaintForTransitionRejectsStaleRoleTokenBeforeRebaseOrReset(t *testing.T) {
895+ d := newTestDaemon(t, nil, stubClock{})
896+ tr := &closeTrackingTransport{}
897+ ac := &attachedClient{
898+ tr: tr,
899+ output: newOutputStateStream(),
900+ size: domain.Size{Cols: 80, Rows: 24},
901+ }
902+ ac.initOverlays()
903+ ac.roleGeneration.Store(2)
904+ ac.output.next = 4
905+ ac.output.acked = 1
906+ sess := &session{id: "work", client: ac}
907+ ac.setSession(sess)
908+ d.sessions[sess.id] = sess
909+ rc := d.attachCoordinator(sess, nil, ac, true)
910+ rebased := false
911+ ac.renderStages.handoffRebase = func() { rebased = true }
912+
913+ stale := attachmentRoleToken{
914+ sess: sess, ac: ac, role: attachmentActive,
915+ generation: 1, transport: ac.transportSnapshot(), lease: rc.attachmentLease(ac), rebase: true,
916+ }
917+ require.False(t, d.firstPaintForTransition(stale))
918+
919+ require.False(t, rebased)
920+ require.Equal(t, uint64(4), ac.output.next)
921+ require.Equal(t, uint64(1), ac.output.acked)
922+ require.Empty(t, tr.Sends(), "stale first paint emitted a reset")
923+}
924+
925+func TestTransitionAttachmentMovesActiveBetweenSessions(t *testing.T) {
926+ d := newTestDaemon(t, nil, stubClock{})
927+ from := &session{id: domain.SessionID("z-source")}
928+ target := &session{id: domain.SessionID("a-target")}
929+ moving := &attachedClient{tr: &closeTrackingTransport{}}
930+ displaced := &attachedClient{tr: &closeTrackingTransport{}}
931+ moving.setSession(from)
932+ displaced.setSession(target)
933+ from.client = moving
934+ target.client = displaced
935+ d.sessions[from.id] = from
936+ d.sessions[target.id] = target
937+
938+ result, err := d.transitionAttachment(attachmentTransitionRequest{
939+ source: from,
940+ target: target,
941+ next: moving,
942+ expectedRole: attachmentActive,
943+ targetRole: attachmentActive,
944+ expectedTransport: moving.transportSnapshot(),
945+ ready: true,
946+ })
947+ require.NoError(t, err)
948+
949+ from.mu.Lock()
950+ require.Nil(t, from.client)
951+ require.NotContains(t, from.snatched, moving)
952+ from.mu.Unlock()
953+ target.mu.Lock()
954+ require.Same(t, moving, target.client)
955+ require.Contains(t, target.snatched, displaced)
956+ target.mu.Unlock()
957+ require.True(t, result.published.current())
958+ require.True(t, result.displaced.current())
959+ require.Same(t, target, moving.currentSession())
960+}
M internal/usecase/daemon/snatched_render.go +16 -2
@@ -59,10 +59,14 @@ var errSnatchedOutputStale = errors.New("snatched output role changed")
59 // sendSnatchedPanel serializes a dependency-free panel reset onto one exact59 // sendSnatchedPanel serializes a dependency-free panel reset onto one exact
60 // attachment role and transport lifetime. It does not inspect session state or60 // attachment role and transport lifetime. It does not inspect session state or
61 // capture pane content.61 // capture pane content.
62-func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapshot, generation uint64, feedback string) error {62+func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapshot, generation uint64, feedback string, effects ...*roleEffectTicket) error {
63 if expected.transport == nil {63 if expected.transport == nil {
64 return errors.New("snatched client transport is nil")64 return errors.New("snatched client transport is nil")
65 }65 }
66+ var effect *roleEffectTicket
67+ if len(effects) != 0 {
68+ effect = effects[0]
69+ }
66 tr, err := d.boundedSendWith(expected.transport, func() error {70 tr, err := d.boundedSendWith(expected.transport, func() error {
67 ac.sendMu.Lock()71 ac.sendMu.Lock()
68 defer ac.sendMu.Unlock()72 defer ac.sendMu.Unlock()
@@ -81,7 +85,17 @@ func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapsho
81 return err85 return err
82 }86 }
83 data := append(append([]byte(nil), prepared.data...), ac.encodeCursorTail(cursorOut{hidden: true}, true)...)87 data := append(append([]byte(nil), prepared.data...), ac.encodeCursorTail(cursorOut{hidden: true}, true)...)
84- return prepared.send(data, ac.echoAck.Load(), expected.transport.Send)88+ if effect != nil && !effect.beginTransportSend(expected) {
89+ return errAttachmentTransition
90+ }
91+ err = prepared.send(data, ac.echoAck.Load(), expected.transport.Send)
92+ if effect != nil {
93+ if err != nil {
94+ effect.reportTransportFailure(expected)
95+ }
96+ effect.endTransportSend()
97+ }
98+ return err
85 })99 })
86 if errors.Is(err, errSendTimedOut) {100 if errors.Is(err, errSendTimedOut) {
87 _ = ac.closeCapturedTransport(tr)101 _ = ac.closeCapturedTransport(tr)
M internal/usecase/daemon/stalled_attachment_test.go +25 -18
@@ -1,8 +1,6 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "io"
5- "sync"
6 "testing"4 "testing"
7 "time"5 "time"
8 6
@@ -14,10 +12,10 @@ import (
14 portsmocks "github.com/bnema/vev/internal/ports/mocks"12 portsmocks "github.com/bnema/vev/internal/ports/mocks"
15 )13 )
16 14
17-// TestFinishAttachDoesNotWaitForBlockedOldRenderWorker verifies the replacement15+// TestFinishAttachWaitsForAdmittedOldRender verifies role-effect
18-// boundary rather than relying on a scheduler delay: an old coordinator worker16+// linearization: an old render already admitted and blocked in Send completes
19-// is already blocked in Send when finishAttach publishes its replacement.17+// before replacement publication can change its capability.
20-func TestFinishAttachDoesNotWaitForBlockedOldRenderWorker(t *testing.T) {18+func TestFinishAttachWaitsForAdmittedOldRender(t *testing.T) {
21 pty, releasePTY := newBlockingPTY(t)19 pty, releasePTY := newBlockingPTY(t)
22 defer releasePTY()20 defer releasePTY()
23 clock := newCoordinatorMockClock(t, 4)21 clock := newCoordinatorMockClock(t, 4)
@@ -25,17 +23,14 @@ func TestFinishAttachDoesNotWaitForBlockedOldRenderWorker(t *testing.T) {
25 23
26 oldTransport := portsmocks.NewMockTransport(t)24 oldTransport := portsmocks.NewMockTransport(t)
27 oldSendEntered := make(chan struct{})25 oldSendEntered := make(chan struct{})
28- oldClosed := make(chan struct{})26+ releaseOldSend := make(chan struct{})
29- var closeOnce sync.Once
30 oldTransport.EXPECT().Send(mock.Anything).RunAndReturn(func(ports.Frame) error {27 oldTransport.EXPECT().Send(mock.Anything).RunAndReturn(func(ports.Frame) error {
31 close(oldSendEntered)28 close(oldSendEntered)
32- <-oldClosed29+ <-releaseOldSend
33- return io.EOF
34- }).Once()
35- oldTransport.EXPECT().Close().RunAndReturn(func() error {
36- closeOnce.Do(func() { close(oldClosed) })
37 return nil30 return nil
38 }).Once()31 }).Once()
32+ oldTransport.EXPECT().Send(mock.Anything).Return(nil).Maybe()
33+ oldTransport.EXPECT().Close().Return(nil).Maybe()
39 34
40 hello := ports.Hello{35 hello := ports.Hello{
41 Version: ports.ProtocolVersion,36 Version: ports.ProtocolVersion,
@@ -60,19 +55,31 @@ func TestFinishAttachDoesNotWaitForBlockedOldRenderWorker(t *testing.T) {
60 attached := make(chan *attachedClient, 1)55 attached := make(chan *attachedClient, 1)
61 go func() {56 go func() {
62 d.mu.Lock()57 d.mu.Lock()
63- attached <- d.finishAttach(sess, replacement, hello.Size, terminalEnv{}, hello)58+ ac, err := d.finishAttach(sess, replacement, hello.Size, terminalEnv{}, hello)
59+ require.NoError(t, err)
60+ attached <- ac
64 }()61 }()
65 62
63+ select {
64+ case <-attached:
65+ t.Fatal("replacement published before the admitted old render completed")
66+ case <-time.After(20 * time.Millisecond):
67+ }
68+ close(releaseOldSend)
66 select {69 select {
67 case ac := <-attached:70 case ac := <-attached:
68 require.Same(t, replacement, ac.transport())71 require.Same(t, replacement, ac.transport())
69 case <-time.After(time.Second):72 case <-time.After(time.Second):
70- t.Fatal("replacement attach waited for the blocked old render worker")73+ t.Fatal("replacement did not publish after the admitted render completed")
71 }74 }
72 75
73- // The exact old link is revoked and closed; it cannot be mistaken for the
74- // replacement even after the retired worker finally leaves Send.
75 d.attachmentCleanupWg.Wait()76 d.attachmentCleanupWg.Wait()
76- require.Nil(t, old.transport())77+ require.Same(t, oldTransport, old.transport(), "the displaced attachment remains snatched")
77 replacement.AssertNotCalled(t, "Close")78 replacement.AssertNotCalled(t, "Close")
79+
80+ // Retire the session before mock cleanup so asynchronous terminal Detached
81+ // notification cannot race the clock's C expectation after the test returns.
82+ releasePTY()
83+ awaitTestCompletion(t, d.done, "session did not retire after its final PTY closed")
84+ d.waitNotifies()
78 }85 }
M internal/usecase/daemon/status_test.go +2 -2
@@ -528,7 +528,7 @@ func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) {
528 }528 }
529 tr, _ := newCapturingTransport(t)529 tr, _ := newCapturingTransport(t)
530 530
531- ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})531+ ac, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
532 532
533 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)533 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)
534 assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)534 assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)
@@ -717,7 +717,7 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
717 require.True(t, sess.detachIfCurrent(ac))717 require.True(t, sess.detachIfCurrent(ac))
718 718
719 tr, _ := newCapturingTransport(t)719 tr, _ := newCapturingTransport(t)
720- newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})720+ newAC, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
721 d.applyTheme(sess, newAC, ports.Theme{721 d.applyTheme(sess, newAC, ports.Theme{
722 HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},722 HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
723 HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},723 HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},
M internal/usecase/daemon/transactional_resize.go +10 -0
@@ -553,6 +553,11 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
553 }553 }
554 554
555 func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {555 func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {
556+ ticket, admitted := beginActiveLeaseEffect(sess, ac, lease)
557+ if !admitted {
558+ return false
559+ }
560+ defer ticket.End()
556 rc := sess.renderCoordinator()561 rc := sess.renderCoordinator()
557 if rc == nil {562 if rc == nil {
558 return false563 return false
@@ -593,6 +598,11 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
593 // only: their rectangles must never cross this delayed boundary, because any598 // only: their rectangles must never cross this delayed boundary, because any
594 // later layout mutation may have changed their geometry or removed them.599 // later layout mutation may have changed their geometry or removed them.
595 func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) {600 func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) {
601+ ticket, admitted := beginActiveLeaseEffect(sess, ac, lease)
602+ if !admitted {
603+ return
604+ }
605+ defer ticket.End()
596 rc := sess.renderCoordinator()606 rc := sess.renderCoordinator()
597 if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {607 if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
598 return608 return
M internal/usecase/daemon/transactional_resize_test.go +33 -11
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "errors"4 "errors"
5 "io"5 "io"
6+ "strings"
6 "sync"7 "sync"
7 "testing"8 "testing"
8 "time"9 "time"
@@ -18,11 +19,12 @@ import (
18 // transactionalResizePTY is deliberately channel-scripted: a test can stop a19 // transactionalResizePTY is deliberately channel-scripted: a test can stop a
19 // transaction at PTY.Resize without relying on scheduler timing.20 // transaction at PTY.Resize without relying on scheduler timing.
20 type transactionalResizePTY struct {21 type transactionalResizePTY struct {
21- mu sync.Mutex22+ mu sync.Mutex
22- sizes []domain.Size23+ sizes []domain.Size
23- errs []error24+ writeData [][]byte
24- onResize func()25+ errs []error
25- onWrite func([]byte)26+ onResize func()
27+ onWrite func([]byte)
26 }28 }
27 29
28 func (p *transactionalResizePTY) Resize(size domain.Size) error {30 func (p *transactionalResizePTY) Resize(size domain.Size) error {
@@ -43,10 +45,12 @@ func (p *transactionalResizePTY) Resize(size domain.Size) error {
43 func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF }45 func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF }
44 func (p *transactionalResizePTY) Write(b []byte) (int, error) {46 func (p *transactionalResizePTY) Write(b []byte) (int, error) {
45 p.mu.Lock()47 p.mu.Lock()
48+ copied := append([]byte(nil), b...)
49+ p.writeData = append(p.writeData, copied)
46 hook := p.onWrite50 hook := p.onWrite
47 p.mu.Unlock()51 p.mu.Unlock()
48 if hook != nil {52 if hook != nil {
49- hook(append([]byte(nil), b...))53+ hook(copied)
50 }54 }
51 return len(b), nil55 return len(b), nil
52 }56 }
@@ -59,6 +63,16 @@ func (p *transactionalResizePTY) requested() []domain.Size {
59 return append([]domain.Size(nil), p.sizes...)63 return append([]domain.Size(nil), p.sizes...)
60 }64 }
61 65
66+func (p *transactionalResizePTY) writes() [][]byte {
67+ p.mu.Lock()
68+ defer p.mu.Unlock()
69+ writes := make([][]byte, len(p.writeData))
70+ for i := range p.writeData {
71+ writes[i] = append([]byte(nil), p.writeData[i]...)
72+ }
73+ return writes
74+}
75+
62 // resizeReaderPTY is a deterministic child: Resize releases its redraw only76 // resizeReaderPTY is a deterministic child: Resize releases its redraw only
63 // after the daemon has installed resizeApplying, and waits until ptyReader has77 // after the daemon has installed resizeApplying, and waits until ptyReader has
64 // accepted that read. It deliberately has no timing dependency.78 // accepted that read. It deliberately has no timing dependency.
@@ -154,7 +168,8 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
154 func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {168 func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
155 responses := make(chan []byte, 1)169 responses := make(chan []byte, 1)
156 pty := &transactionalResizePTY{onWrite: func(b []byte) { responses <- b }}170 pty := &transactionalResizePTY{onWrite: func(b []byte) { responses <- b }}
157- d, sess, _, sends := newManualSessionWithPTYs(t, pty)171+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
172+ publishActiveClipboardCapability(d, sess, ac, ac.transport())
158 tb, p := sess.activeTab(), sess.activeTab().focusedPane()173 tb, p := sess.activeTab(), sess.activeTab().focusedPane()
159 p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }174 p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
160 p.screen.OnBell = func() { p.ptyAttention = true }175 p.screen.OnBell = func() { p.ptyAttention = true }
@@ -171,10 +186,17 @@ func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
171 require.NotEmpty(t, <-responses, "DSR response must be flushed back to the child")186 require.NotEmpty(t, <-responses, "DSR response must be flushed back to the child")
172 // DSR is flushed back to the child and OSC 52 is forwarded through the187 // DSR is flushed back to the child and OSC 52 is forwarded through the
173 // normal asynchronous client path, proving replay uses processPTYData.188 // normal asynchronous client path, proving replay uses processPTYData.
174- frame := awaitFrame(t, sends, ports.MsgOutput)189+ var clipboardOutput string
175- out, err := ports.UnmarshalOutput(frame.Payload)190+ for range 3 {
176- require.NoError(t, err)191+ frame := awaitFrame(t, sends, ports.MsgOutput)
177- require.Contains(t, string(out.Data), "\x1b]52;c;YQ==\a")192+ out, err := ports.UnmarshalOutput(frame.Payload)
193+ require.NoError(t, err)
194+ clipboardOutput = string(out.Data)
195+ if strings.Contains(clipboardOutput, "\x1b]52;c;YQ==\a") {
196+ break
197+ }
198+ }
199+ require.Contains(t, clipboardOutput, "\x1b]52;c;YQ==\a")
178 }200 }
179 201
180 // S3 acceptance: prepare may inspect layout under its locks, but apply must202 // S3 acceptance: prepare may inspect layout under its locks, but apply must
M internal/usecase/keys/router.go +43 -29
@@ -61,11 +61,12 @@ type Router struct {
61 h Handler61 h Handler
62 bindings *atomic.Pointer[Bindings]62 bindings *atomic.Pointer[Bindings]
63 63
64- mu sync.Mutex64+ mu sync.Mutex
65- pending bool65+ pending bool
66- pendingAlt []byte66+ pendingAlt []byte
67- timer ports.Timer67+ pendingHandler Handler
68- pendingDone chan struct{}68+ timer ports.Timer
69+ pendingDone chan struct{}
69 }70 }
70 71
71 // NewRouter constructs a Router. bindings may be nil, in which case72 // NewRouter constructs a Router. bindings may be nil, in which case
@@ -74,33 +75,43 @@ func NewRouter(clock ports.Clock, h Handler, bindings *atomic.Pointer[Bindings])
74 return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindings}75 return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindings}
75 }76 }
76 77
77-// Route routes one transport read.78+// Route routes one transport read with the router's default handler.
78-func (r *Router) Route(data []byte) {79+func (r *Router) Route(data []byte) { r.RouteWithHandler(data, r.h) }
80+
81+// RouteWithHandler binds every synchronous or delayed result from data to the
82+// supplied handler. A retained ESC therefore cannot gain authority from a
83+// later transport frame after the original frame's ownership token is stale.
84+func (r *Router) RouteWithHandler(data []byte, h Handler) {
79 if len(data) == 0 {85 if len(data) == 0 {
80 return86 return
81 }87 }
88+ if h == nil {
89+ h = r.h
90+ }
82 r.mu.Lock()91 r.mu.Lock()
83 defer r.mu.Unlock()92 defer r.mu.Unlock()
84 if r.pending {93 if r.pending {
85 pendingAlt := r.pendingAlt94 pendingAlt := r.pendingAlt
95+ pendingHandler := r.pendingHandler
86 r.stopTimer()96 r.stopTimer()
87 r.pending = false97 r.pending = false
88 r.pendingAlt = nil98 r.pendingAlt = nil
99+ r.pendingHandler = nil
89 combined := append(append([]byte(nil), pendingAlt...), data...)100 combined := append(append([]byte(nil), pendingAlt...), data...)
90- if consumed := r.routeAfterPendingESC(combined, len(pendingAlt)); consumed > len(pendingAlt) {101+ if consumed := r.routeAfterPendingESC(combined, len(pendingAlt), pendingHandler); consumed > len(pendingAlt) {
91 data = data[consumed-len(pendingAlt):]102 data = data[consumed-len(pendingAlt):]
92 }103 }
93 }104 }
94- r.route(data)105+ r.route(data, h)
95 }106 }
96 107
97-func (r *Router) route(data []byte) {108+func (r *Router) route(data []byte, h Handler) {
98 buf := make([]byte, 0, len(data))109 buf := make([]byte, 0, len(data))
99 flush := func() {110 flush := func() {
100 if len(buf) == 0 {111 if len(buf) == 0 {
101 return112 return
102 }113 }
103- r.forward(buf)114+ r.forward(buf, h)
104 buf = buf[:0]115 buf = buf[:0]
105 }116 }
106 for i := 0; i < len(data); {117 for i := 0; i < len(data); {
@@ -111,7 +122,7 @@ func (r *Router) route(data []byte) {
111 }122 }
112 if i == len(data)-1 {123 if i == len(data)-1 {
113 flush()124 flush()
114- r.retainESC()125+ r.retainESC(h)
115 return126 return
116 }127 }
117 remaining := data[i+1:]128 remaining := data[i+1:]
@@ -132,12 +143,12 @@ func (r *Router) route(data []byte) {
132 }143 }
133 if action, size, ok, partial := r.altArrowCSI(remaining); ok {144 if action, size, ok, partial := r.altArrowCSI(remaining); ok {
134 flush()145 flush()
135- r.h.Action(action)146+ h.Action(action)
136 i += 1 + size147 i += 1 + size
137 continue148 continue
138 } else if partial {149 } else if partial {
139 flush()150 flush()
140- r.retainESC(remaining)151+ r.retainESC(h, remaining)
141 return152 return
142 }153 }
143 next := data[i+1]154 next := data[i+1]
@@ -148,13 +159,13 @@ func (r *Router) route(data []byte) {
148 }159 }
149 if action, size, ok := r.binding(remaining); ok {160 if action, size, ok := r.binding(remaining); ok {
150 flush()161 flush()
151- r.h.Action(action)162+ h.Action(action)
152 i += 1 + size163 i += 1 + size
153 continue164 continue
154 }165 }
155 if partialUTF8Rune(remaining) {166 if partialUTF8Rune(remaining) {
156 flush()167 flush()
157- r.retainESC(remaining)168+ r.retainESC(h, remaining)
158 return169 return
159 }170 }
160 buf = append(buf, ESC)171 buf = append(buf, ESC)
@@ -163,43 +174,44 @@ func (r *Router) route(data []byte) {
163 flush()174 flush()
164 }175 }
165 176
166-func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {177+func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) int {
167 if action, size, ok, partial := r.altArrowCSI(data); ok {178 if action, size, ok, partial := r.altArrowCSI(data); ok {
168- r.h.Action(action)179+ h.Action(action)
169 return size180 return size
170 } else if partial {181 } else if partial {
171- r.retainESC(data)182+ r.retainESC(h, data)
172 return len(data)183 return len(data)
173 }184 }
174 next := data[0]185 next := data[0]
175 if action, size, ok := r.binding(data); ok {186 if action, size, ok := r.binding(data); ok {
176- r.h.Action(action)187+ h.Action(action)
177 return size188 return size
178 }189 }
179 if partialUTF8Rune(data) {190 if partialUTF8Rune(data) {
180- r.retainESC(data)191+ r.retainESC(h, data)
181 return len(data)192 return len(data)
182 }193 }
183 _, size := utf8.DecodeRune(data)194 _, size := utf8.DecodeRune(data)
184 if size > 1 {195 if size > 1 {
185- r.forward(append([]byte{ESC}, data[:size]...))196+ r.forward(append([]byte{ESC}, data[:size]...), h)
186 return size197 return size
187 }198 }
188 if pendingAltLen > 0 {199 if pendingAltLen > 0 {
189- r.forward(append([]byte{ESC}, data[:pendingAltLen]...))200+ r.forward(append([]byte{ESC}, data[:pendingAltLen]...), h)
190 return pendingAltLen201 return pendingAltLen
191 }202 }
192 if passThroughPrefix(next) {203 if passThroughPrefix(next) {
193- r.forward([]byte{ESC, next})204+ r.forward([]byte{ESC, next}, h)
194 return 1205 return 1
195 }206 }
196- r.forward([]byte{ESC})207+ r.forward([]byte{ESC}, h)
197 return 0208 return 0
198 }209 }
199 210
200-func (r *Router) retainESC(altBytes ...[]byte) {211+func (r *Router) retainESC(h Handler, altBytes ...[]byte) {
201 r.pending = true212 r.pending = true
202 r.pendingAlt = nil213 r.pendingAlt = nil
214+ r.pendingHandler = h
203 if len(altBytes) > 0 {215 if len(altBytes) > 0 {
204 r.pendingAlt = append([]byte(nil), altBytes[0]...)216 r.pendingAlt = append([]byte(nil), altBytes[0]...)
205 }217 }
@@ -216,8 +228,10 @@ func (r *Router) retainESC(altBytes ...[]byte) {
216 if r.pending && r.timer == timer {228 if r.pending && r.timer == timer {
217 r.pending = false229 r.pending = false
218 data := append([]byte{ESC}, r.pendingAlt...)230 data := append([]byte{ESC}, r.pendingAlt...)
231+ h := r.pendingHandler
219 r.pendingAlt = nil232 r.pendingAlt = nil
220- r.forward(data)233+ r.pendingHandler = nil
234+ r.forward(data, h)
221 }235 }
222 }(r.timer, r.pendingDone)236 }(r.timer, r.pendingDone)
223 }237 }
@@ -241,9 +255,9 @@ func partialUTF8Rune(data []byte) bool {
241 return key == utf8.RuneError && size == 1255 return key == utf8.RuneError && size == 1
242 }256 }
243 257
244-func (r *Router) forward(data []byte) {258+func (r *Router) forward(data []byte, h Handler) {
245 cp := append([]byte(nil), data...)259 cp := append([]byte(nil), data...)
246- r.h.Forward(cp)260+ h.Forward(cp)
247 }261 }
248 262
249 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }263 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