detached 800c63b

fix(daemon): integrate attachment review fixes

commit
800c63bd00625beb5f16e543df0b7b1e97d0d8fa
author
brice · · 2026-08-03 05:35 UTC
committer
brice · · 2026-08-03 20:53 UTC
parent
74d1510
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
32 files · +707 -486
M internal/usecase/daemon/attachment_registry.go +20 -0
@@ -477,6 +477,26 @@ func (s *session) paneForAttachment(ac *attachedClient) (*tab, *pane) {
477477 return tb, tb.focusedPane()
478478 }
479479
480+// setAttachmentPaneLocked publishes the initiating attachment's pane target
481+// after a shared focus mutation. Caller holds s.mu; the structural tree focus
482+// is not used to resolve any other attachment's target.
483+func (s *session) setAttachmentPaneLocked(ac *attachedClient, tb *tab, p *pane) bool {
484+ if s == nil || ac == nil || tb == nil || p == nil || !attachmentRegisteredLocked(s, ac) {
485+ return false
486+ }
487+ view := ac.viewSnapshot()
488+ before := view
489+ view.tabID = domain.TabStableID(tb.stableID)
490+ view.paneID = domain.PaneStableID(p.stableID)
491+ view = s.repairAttachmentViewLocked(ac, view)
492+ if view.tabID == before.tabID && view.paneID == before.paneID {
493+ return false
494+ }
495+ view.revision++
496+ ac.publishView(view)
497+ return true
498+}
499+
480500 // updateAttachmentView applies one attachment-local view mutation at the
481501 // shared mutation boundary. The revision increments exactly once and target
482502 // IDs are repaired before publication.
@@ -477,6 +477,26 @@ func (s *session) paneForAttachment(ac *attachedClient) (*tab, *pane) {
477 return tb, tb.focusedPane()477 return tb, tb.focusedPane()
478 }478 }
479 479
480+// setAttachmentPaneLocked publishes the initiating attachment's pane target
481+// after a shared focus mutation. Caller holds s.mu; the structural tree focus
482+// is not used to resolve any other attachment's target.
483+func (s *session) setAttachmentPaneLocked(ac *attachedClient, tb *tab, p *pane) bool {
484+ if s == nil || ac == nil || tb == nil || p == nil || !attachmentRegisteredLocked(s, ac) {
485+ return false
486+ }
487+ view := ac.viewSnapshot()
488+ before := view
489+ view.tabID = domain.TabStableID(tb.stableID)
490+ view.paneID = domain.PaneStableID(p.stableID)
491+ view = s.repairAttachmentViewLocked(ac, view)
492+ if view.tabID == before.tabID && view.paneID == before.paneID {
493+ return false
494+ }
495+ view.revision++
496+ ac.publishView(view)
497+ return true
498+}
499+
480 // updateAttachmentView applies one attachment-local view mutation at the500 // updateAttachmentView applies one attachment-local view mutation at the
481 // shared mutation boundary. The revision increments exactly once and target501 // shared mutation boundary. The revision increments exactly once and target
482 // IDs are repaired before publication.502 // IDs are repaired before publication.
M internal/usecase/daemon/attachment_transition_publish.go +1 -7
@@ -200,13 +200,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
200200 }
201201 var lease *attachmentLease
202202 if publication.targetCoordinator != nil && !req.preserveAttachment {
203- if publication.targetCoordinator.lease == nil || !publication.targetCoordinator.lease.active {
204- var cleanup renderLifecycleCleanup
205- cleanup, lease = publication.targetCoordinator.beginReplaceLocked(nil, req.next, req.ready)
206- result.cleanups = append(result.cleanups, cleanup)
207- } else if publication.targetCoordinator.lease.attachment == req.next {
208- lease = publication.targetCoordinator.lease
209- }
203+ lease = publication.targetCoordinator.attachWithReadinessLocked(req.next, req.ready)
210204 }
211205 result.published = attachmentConnectionToken{
212206 sess: req.target, ac: req.next,
@@ -200,13 +200,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
200 }200 }
201 var lease *attachmentLease201 var lease *attachmentLease
202 if publication.targetCoordinator != nil && !req.preserveAttachment {202 if publication.targetCoordinator != nil && !req.preserveAttachment {
203- if publication.targetCoordinator.lease == nil || !publication.targetCoordinator.lease.active {203+ lease = publication.targetCoordinator.attachWithReadinessLocked(req.next, req.ready)
204- var cleanup renderLifecycleCleanup
205- cleanup, lease = publication.targetCoordinator.beginReplaceLocked(nil, req.next, req.ready)
206- result.cleanups = append(result.cleanups, cleanup)
207- } else if publication.targetCoordinator.lease.attachment == req.next {
208- lease = publication.targetCoordinator.lease
209- }
210 }204 }
211 result.published = attachmentConnectionToken{205 result.published = attachmentConnectionToken{
212 sess: req.target, ac: req.next,206 sess: req.target, ac: req.next,
M internal/usecase/daemon/client.go +24 -42
@@ -239,7 +239,7 @@ func (ac *attachedClient) replaceTransport(tr ports.Transport) {
239239
240240 // revokeTransport removes tr only if it is still this attachment's current
241241 // link. It returns the revoked transport, so asynchronous retirement can
242-// close exactly that link without touching a later resume/replacement.
242+// close exactly that link without touching a later connection incarnation.
243243 func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport {
244244 if tr == nil {
245245 return nil
@@ -577,24 +577,21 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
577577 return ac
578578 }
579579
580-// attachCoordinator is the sole direct attachment handoff. It creates at
581-// most one coordinator for sess and changes its identity before any caller
582-// can publish resize or render state for the new client.
580+// attachCoordinator installs the coordinator lease for an attachment. Session
581+// membership remains independent, so installing one attachment never displaces
582+// another.
583583 func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {
584584 rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready)
585585 cleanup.finish()
586586 return rc
587587 }
588588
589-func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
589+func (d *Daemon) attachCoordinatorDeferred(sess *session, _ *attachedClient, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
590590 rc := d.ensureRenderCoordinator(sess)
591- var cleanup renderLifecycleCleanup
592- if old != nil {
593- cleanup = rc.beginReplace(old, current, ready)
594- } else if current != nil {
591+ if current != nil {
595592 rc.attachWithReadiness(current, ready)
596593 }
597- return rc, cleanup
594+ return rc, renderLifecycleCleanup{}
598595 }
599596
600597 // ensureRenderCoordinator publishes the session's single coordinator without
@@ -624,24 +621,24 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
624621 clock: d.clock,
625622 observer: d.runtimeObserver,
626623 wake: func(w renderWake) {
627- // The wake retains the exact primary lease. Paint the primary first,
628- // then fan the same shared-session state out to every live attachment
629- // using each attachment's own output stream and renderer shadow.
630- if w.lease == nil || !rc.wakeCurrent(w) {
631- return
632- }
633- primary := w.lease.attachment
634- d.paint(entry, primary, w.reset, w.lease)
635624 if !rc.wakeCurrent(w) {
636625 return
637626 }
627+ // The session snapshot is deterministic. Each paint revalidates the
628+ // attachment's membership and transport fence after that snapshot, so
629+ // a detached peer cannot stop remaining attachments receiving output.
638630 attachments := snapshotAttachmentSession(entry)
639- for _, attachment := range attachments {
640- if attachment != primary {
641- // Pane damage is session-shared and the primary capture consumes it.
642- // ponytail: secondary full redraws avoid per-attachment damage tracking; add attachment-scoped damage when fanout cost matters.
643- d.paint(entry, attachment, true, nil)
631+ for index, attachment := range attachments {
632+ if !rc.wakeCurrent(w) {
633+ return
644634 }
635+ lease := w.attachmentLeases[attachment]
636+ if lease == nil || !rc.leaseCurrent(lease, true) {
637+ continue
638+ }
639+ // Pane damage is session-shared; later attachments need a complete
640+ // frame because the first capture consumes shared damage.
641+ d.paint(entry, attachment, w.reset || index != 0, lease)
645642 }
646643 },
647644 ackReady: func() bool {
@@ -650,11 +647,11 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
650647 attached.sendMu.Lock()
651648 ready := attached.output == nil || !attached.output.atCapacity()
652649 attached.sendMu.Unlock()
653- if ready {
654- return true
650+ if !ready {
651+ return false
655652 }
656653 }
657- return false
654+ return true
658655 },
659656 })
660657 installAttachmentRenderCoordinator(entry, rc)
@@ -735,21 +732,6 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
735732 if tb == nil {
736733 return
737734 }
738- // The coordinator lease is only the primary render lifecycle. A secondary
739- // attachment must paint directly: entering the nil-lease resize/activation
740- // path would hand the session coordinator to it and strand the first client.
741- secondary := false
742- if lease == nil {
743- if rc := sess.renderCoordinator(); rc != nil {
744- rc.mu.Lock()
745- secondary = rc.lease != nil && rc.lease.active && rc.lease.attachment != ac
746- rc.mu.Unlock()
747- }
748- }
749- if secondary {
750- d.paint(sess, ac, true, nil)
751- return
752- }
753735 tb.mu.Lock()
754736 wsz := tb.size
755737 tb.mu.Unlock()
@@ -906,7 +888,7 @@ func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice po
906888 }
907889
908890 // recordClientNotice mutates notice history and the selected attachment while
909-// routingMu keeps replacement from changing that selection. Rendering is left
891+// routingMu keeps another connection from changing that selection. Rendering is left
910892 // to the caller after it releases routingMu.
911893 func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool {
912894 n := d.notices.record(domain.Notification{
@@ -239,7 +239,7 @@ func (ac *attachedClient) replaceTransport(tr ports.Transport) {
239 239
240 // revokeTransport removes tr only if it is still this attachment's current240 // revokeTransport removes tr only if it is still this attachment's current
241 // link. It returns the revoked transport, so asynchronous retirement can241 // link. It returns the revoked transport, so asynchronous retirement can
242-// close exactly that link without touching a later resume/replacement.242+// close exactly that link without touching a later connection incarnation.
243 func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport {243 func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport {
244 if tr == nil {244 if tr == nil {
245 return nil245 return nil
@@ -577,24 +577,21 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
577 return ac577 return ac
578 }578 }
579 579
580-// attachCoordinator is the sole direct attachment handoff. It creates at580+// attachCoordinator installs the coordinator lease for an attachment. Session
581-// most one coordinator for sess and changes its identity before any caller581+// membership remains independent, so installing one attachment never displaces
582-// can publish resize or render state for the new client.582+// another.
583 func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {583 func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {
584 rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready)584 rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready)
585 cleanup.finish()585 cleanup.finish()
586 return rc586 return rc
587 }587 }
588 588
589-func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {589+func (d *Daemon) attachCoordinatorDeferred(sess *session, _ *attachedClient, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
590 rc := d.ensureRenderCoordinator(sess)590 rc := d.ensureRenderCoordinator(sess)
591- var cleanup renderLifecycleCleanup591+ if current != nil {
592- if old != nil {
593- cleanup = rc.beginReplace(old, current, ready)
594- } else if current != nil {
595 rc.attachWithReadiness(current, ready)592 rc.attachWithReadiness(current, ready)
596 }593 }
597- return rc, cleanup594+ return rc, renderLifecycleCleanup{}
598 }595 }
599 596
600 // ensureRenderCoordinator publishes the session's single coordinator without597 // ensureRenderCoordinator publishes the session's single coordinator without
@@ -624,24 +621,24 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
624 clock: d.clock,621 clock: d.clock,
625 observer: d.runtimeObserver,622 observer: d.runtimeObserver,
626 wake: func(w renderWake) {623 wake: func(w renderWake) {
627- // The wake retains the exact primary lease. Paint the primary first,
628- // then fan the same shared-session state out to every live attachment
629- // using each attachment's own output stream and renderer shadow.
630- if w.lease == nil || !rc.wakeCurrent(w) {
631- return
632- }
633- primary := w.lease.attachment
634- d.paint(entry, primary, w.reset, w.lease)
635 if !rc.wakeCurrent(w) {624 if !rc.wakeCurrent(w) {
636 return625 return
637 }626 }
627+ // The session snapshot is deterministic. Each paint revalidates the
628+ // attachment's membership and transport fence after that snapshot, so
629+ // a detached peer cannot stop remaining attachments receiving output.
638 attachments := snapshotAttachmentSession(entry)630 attachments := snapshotAttachmentSession(entry)
639- for _, attachment := range attachments {631+ for index, attachment := range attachments {
640- if attachment != primary {632+ if !rc.wakeCurrent(w) {
641- // Pane damage is session-shared and the primary capture consumes it.633+ return
642- // ponytail: secondary full redraws avoid per-attachment damage tracking; add attachment-scoped damage when fanout cost matters.
643- d.paint(entry, attachment, true, nil)
644 }634 }
635+ lease := w.attachmentLeases[attachment]
636+ if lease == nil || !rc.leaseCurrent(lease, true) {
637+ continue
638+ }
639+ // Pane damage is session-shared; later attachments need a complete
640+ // frame because the first capture consumes shared damage.
641+ d.paint(entry, attachment, w.reset || index != 0, lease)
645 }642 }
646 },643 },
647 ackReady: func() bool {644 ackReady: func() bool {
@@ -650,11 +647,11 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
650 attached.sendMu.Lock()647 attached.sendMu.Lock()
651 ready := attached.output == nil || !attached.output.atCapacity()648 ready := attached.output == nil || !attached.output.atCapacity()
652 attached.sendMu.Unlock()649 attached.sendMu.Unlock()
653- if ready {650+ if !ready {
654- return true651+ return false
655 }652 }
656 }653 }
657- return false654+ return true
658 },655 },
659 })656 })
660 installAttachmentRenderCoordinator(entry, rc)657 installAttachmentRenderCoordinator(entry, rc)
@@ -735,21 +732,6 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
735 if tb == nil {732 if tb == nil {
736 return733 return
737 }734 }
738- // The coordinator lease is only the primary render lifecycle. A secondary
739- // attachment must paint directly: entering the nil-lease resize/activation
740- // path would hand the session coordinator to it and strand the first client.
741- secondary := false
742- if lease == nil {
743- if rc := sess.renderCoordinator(); rc != nil {
744- rc.mu.Lock()
745- secondary = rc.lease != nil && rc.lease.active && rc.lease.attachment != ac
746- rc.mu.Unlock()
747- }
748- }
749- if secondary {
750- d.paint(sess, ac, true, nil)
751- return
752- }
753 tb.mu.Lock()735 tb.mu.Lock()
754 wsz := tb.size736 wsz := tb.size
755 tb.mu.Unlock()737 tb.mu.Unlock()
@@ -906,7 +888,7 @@ func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice po
906 }888 }
907 889
908 // recordClientNotice mutates notice history and the selected attachment while890 // recordClientNotice mutates notice history and the selected attachment while
909-// routingMu keeps replacement from changing that selection. Rendering is left891+// routingMu keeps another connection from changing that selection. Rendering is left
910 // to the caller after it releases routingMu.892 // to the caller after it releases routingMu.
911 func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool {893 func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool {
912 n := d.notices.record(domain.Notification{894 n := d.notices.record(domain.Notification{
M internal/usecase/daemon/client_frame_routing.go +5 -3
@@ -218,9 +218,11 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
218218
219219 // The frame's attachment token is the target capability. No registry/name lookup is
220220 // performed, so the command cannot escape to another remote session.
221- sess.dispatchMu.Lock()
222- err := cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args)
223- sess.dispatchMu.Unlock()
221+ var err error
222+ _ = sess.runMutation(func() error {
223+ err = cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args)
224+ return nil
225+ })
224226 if err == nil {
225227 result.OK = true
226228 return result
@@ -218,9 +218,11 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
218 218
219 // The frame's attachment token is the target capability. No registry/name lookup is219 // The frame's attachment token is the target capability. No registry/name lookup is
220 // performed, so the command cannot escape to another remote session.220 // performed, so the command cannot escape to another remote session.
221- sess.dispatchMu.Lock()221+ var err error
222- err := cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args)222+ _ = sess.runMutation(func() error {
223- sess.dispatchMu.Unlock()223+ err = cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args)
224+ return nil
225+ })
224 if err == nil {226 if err == nil {
225 result.OK = true227 result.OK = true
226 return result228 return result
M internal/usecase/daemon/control.go +24 -7
@@ -276,17 +276,21 @@ func resolveDaemonActionTargetForAttachment(sess *session, ac *attachedClient) d
276276 if sess == nil {
277277 return daemonActionTarget{}
278278 }
279- tb := sess.tabForAttachment(ac)
280- if ac == nil {
281- tb = sess.firstTab()
279+ if ac != nil {
280+ tb, pane := sess.paneForAttachment(ac)
281+ if tb == nil {
282+ return daemonActionTarget{session: sess, attachment: ac}
283+ }
284+ return daemonActionTarget{session: sess, attachment: ac, tab: tb, pane: pane}
282285 }
286+ tb := sess.firstTab()
283287 if tb == nil {
284288 return daemonActionTarget{session: sess}
285289 }
286290 tb.mu.Lock()
287291 pane := tb.focusedPane()
288292 tb.mu.Unlock()
289- return daemonActionTarget{session: sess, attachment: ac, tab: tb, pane: pane}
293+ return daemonActionTarget{session: sess, tab: tb, pane: pane}
290294 }
291295
292296 // daemonActions is the daemon-owned mutation seam shared by palette and
@@ -322,7 +326,7 @@ func (a daemonActions) Run(request daemonActionRequest) error {
322326 }
323327 return nil
324328 case daemonActionFocusPane:
325- _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)
329+ _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction, target.attachment)
326330 return err
327331 case daemonActionNextTab:
328332 return a.switchRelative(target.session, target.attachment, 1)
@@ -704,8 +708,17 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) {
704708 e.sess.mu.Lock()
705709 fallbackCWD := e.sess.cwd
706710 e.sess.mu.Unlock()
711+ var focus layout.PaneID
712+ var focusStableID domain.PaneStableID
713+ if e.target.attachment != nil {
714+ focusStableID = e.target.attachment.viewSnapshot().paneID
715+ }
707716 tb.mu.Lock()
708- focus := tb.tree.Focus
717+ if e.target.attachment == nil {
718+ if focused := tb.focusedPane(); focused != nil {
719+ focus = focused.id
720+ }
721+ }
709722 panes := tb.panesSnapshot()
710723 tb.mu.Unlock()
711724 rows := make([]row, 0, len(panes))
@@ -723,7 +736,11 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) {
723736 cwd = live
724737 }
725738 }
726- rows = append(rows, row{ID: pane.stableID, Pane: string(pane.id), Size: fmt.Sprintf("%dx%d", size.Cols, size.Rows), CWD: cwd, Focused: pane.id == focus})
739+ focused := pane.id == focus
740+ if e.target.attachment != nil {
741+ focused = domain.PaneStableID(pane.stableID) == focusStableID
742+ }
743+ rows = append(rows, row{ID: pane.stableID, Pane: string(pane.id), Size: fmt.Sprintf("%dx%d", size.Cols, size.Rows), CWD: cwd, Focused: focused})
727744 }
728745 sort.Slice(rows, func(i, j int) bool { return rows[i].Pane < rows[j].Pane })
729746 if asJSON {
@@ -276,17 +276,21 @@ func resolveDaemonActionTargetForAttachment(sess *session, ac *attachedClient) d
276 if sess == nil {276 if sess == nil {
277 return daemonActionTarget{}277 return daemonActionTarget{}
278 }278 }
279- tb := sess.tabForAttachment(ac)279+ if ac != nil {
280- if ac == nil {280+ tb, pane := sess.paneForAttachment(ac)
281- tb = sess.firstTab()281+ if tb == nil {
282+ return daemonActionTarget{session: sess, attachment: ac}
283+ }
284+ return daemonActionTarget{session: sess, attachment: ac, tab: tb, pane: pane}
282 }285 }
286+ tb := sess.firstTab()
283 if tb == nil {287 if tb == nil {
284 return daemonActionTarget{session: sess}288 return daemonActionTarget{session: sess}
285 }289 }
286 tb.mu.Lock()290 tb.mu.Lock()
287 pane := tb.focusedPane()291 pane := tb.focusedPane()
288 tb.mu.Unlock()292 tb.mu.Unlock()
289- return daemonActionTarget{session: sess, attachment: ac, tab: tb, pane: pane}293+ return daemonActionTarget{session: sess, tab: tb, pane: pane}
290 }294 }
291 295
292 // daemonActions is the daemon-owned mutation seam shared by palette and296 // daemonActions is the daemon-owned mutation seam shared by palette and
@@ -322,7 +326,7 @@ func (a daemonActions) Run(request daemonActionRequest) error {
322 }326 }
323 return nil327 return nil
324 case daemonActionFocusPane:328 case daemonActionFocusPane:
325- _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)329+ _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction, target.attachment)
326 return err330 return err
327 case daemonActionNextTab:331 case daemonActionNextTab:
328 return a.switchRelative(target.session, target.attachment, 1)332 return a.switchRelative(target.session, target.attachment, 1)
@@ -704,8 +708,17 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) {
704 e.sess.mu.Lock()708 e.sess.mu.Lock()
705 fallbackCWD := e.sess.cwd709 fallbackCWD := e.sess.cwd
706 e.sess.mu.Unlock()710 e.sess.mu.Unlock()
711+ var focus layout.PaneID
712+ var focusStableID domain.PaneStableID
713+ if e.target.attachment != nil {
714+ focusStableID = e.target.attachment.viewSnapshot().paneID
715+ }
707 tb.mu.Lock()716 tb.mu.Lock()
708- focus := tb.tree.Focus717+ if e.target.attachment == nil {
718+ if focused := tb.focusedPane(); focused != nil {
719+ focus = focused.id
720+ }
721+ }
709 panes := tb.panesSnapshot()722 panes := tb.panesSnapshot()
710 tb.mu.Unlock()723 tb.mu.Unlock()
711 rows := make([]row, 0, len(panes))724 rows := make([]row, 0, len(panes))
@@ -723,7 +736,11 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) {
723 cwd = live736 cwd = live
724 }737 }
725 }738 }
726- rows = append(rows, row{ID: pane.stableID, Pane: string(pane.id), Size: fmt.Sprintf("%dx%d", size.Cols, size.Rows), CWD: cwd, Focused: pane.id == focus})739+ focused := pane.id == focus
740+ if e.target.attachment != nil {
741+ focused = domain.PaneStableID(pane.stableID) == focusStableID
742+ }
743+ rows = append(rows, row{ID: pane.stableID, Pane: string(pane.id), Size: fmt.Sprintf("%dx%d", size.Cols, size.Rows), CWD: cwd, Focused: focused})
727 }744 }
728 sort.Slice(rows, func(i, j int) bool { return rows[i].Pane < rows[j].Pane })745 sort.Slice(rows, func(i, j int) bool { return rows[i].Pane < rows[j].Pane })
729 if asJSON {746 if asJSON {
M internal/usecase/daemon/floating_render_test.go +1 -1
@@ -617,7 +617,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
617617 cache := composeCacheInput{}
618618 for range 500 {
619619 ac.sendMu.Lock()
620- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: base, floatingCfg: cfg})
620+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{bars: base, floatingCfg: cfg})
621621 ac.sendMu.Unlock()
622622 require.True(t, ok)
623623 out := composeFrame(*state, cache)
@@ -617,7 +617,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
617 cache := composeCacheInput{}617 cache := composeCacheInput{}
618 for range 500 {618 for range 500 {
619 ac.sendMu.Lock()619 ac.sendMu.Lock()
620- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: base, floatingCfg: cfg})620+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{bars: base, floatingCfg: cfg})
621 ac.sendMu.Unlock()621 ac.sendMu.Unlock()
622 require.True(t, ok)622 require.True(t, ok)
623 out := composeFrame(*state, cache)623 out := composeFrame(*state, cache)
M internal/usecase/daemon/input.go +164 -80
@@ -82,6 +82,10 @@ func (d *Daemon) handleInputForAttachment(token attachmentConnectionToken, data
8282 }
8383
8484 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
85+ d.handleMouseMutation(ac, ev)
86+}
87+
88+func (d *Daemon) handleMouseMutation(ac *attachedClient, ev mouse.Event) {
8589 frameEvent := ev
8690 ac.initOverlays()
8791 rt := ac.overlays
@@ -108,97 +112,157 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
108112 return
109113 }
110114
111- tb.mu.Lock()
115+ // Copy-mode publication deliberately revalidates outside the dispatch lock:
116+ // a concurrent release or pane close must be able to invalidate its candidate.
117+ var routed mouseRoute
118+ _ = sess.runMutation(func() error {
119+ routed = d.routeMouseMutation(sess, ac, ev)
120+ return nil
121+ })
122+ if routed.handled {
123+ return
124+ }
125+ if routed.p == nil {
126+ invalidateRejectedLeftPointer(rt, ev)
127+ return
128+ }
129+ if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, routed.tb, routed.p, frameEvent) {
130+ return
131+ }
132+ d.handleTerminalMouse(sess, ac, routed.p, routed.event, routed.translated, routed.hoveredFocused)
133+}
134+
135+type mouseRoute struct {
136+ tb *tab
137+ p *pane
138+ event mouse.Event
139+ translated bool
140+ hoveredFocused bool
141+ handled bool
142+}
143+
144+func (d *Daemon) routeMouseMutation(sess *session, ac *attachedClient, ev mouse.Event) mouseRoute {
145+ route := mouseRoute{event: ev, hoveredFocused: true}
146+ tb := sess.tabForAttachment(ac)
147+ if tb == nil {
148+ route.handled = true
149+ return route
150+ }
151+ route.tb = tb
112152 contentRow := ev.Row - clientTopBarRows
153+ tb.mu.Lock()
113154 floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
114155 if floatingVisible {
115156 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
116157 tb.mu.Unlock()
117- invalidateRejectedLeftPointer(rt, ev)
118- return
158+ route.handled = true
159+ return route
119160 }
120161 tb.mu.Unlock()
121- if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, floating, frameEvent) {
122- return
123- }
124- ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y)
125- d.handleTerminalMouse(sess, ac, floating, ev, true, true)
126- return
162+ route.p = floating
163+ route.event = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y)
164+ route.translated = true
165+ return route
127166 }
128167 pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)
129168 multi := len(tb.panes) > 1
130- focusedID := layout.PaneID("")
131- if tb.tree != nil {
132- focusedID = tb.tree.Focus
133- }
169+ focusedID := mouseFocusedPaneIDLocked(ac, tb)
134170 if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {
135171 if !isMouseFocusPress(ev) {
136172 tb.mu.Unlock()
137- invalidateRejectedLeftPointer(rt, ev)
138- return
173+ route.handled = true
174+ return route
139175 }
140- oldFocus := focusedID
141- layoutChanged := focusPlacementLocked(tb, pl.ID)
142176 tb.mu.Unlock()
143- if layoutChanged {
144- d.applyTabLayout(sess, tb)
177+ _, focused := d.focusMousePane(sess, ac, tb, pl.ID)
178+ if focused == nil {
179+ route.handled = true
180+ return route
145181 }
146- // A title bar never routes to terminal content. Clear any pre-existing
147- // left-button candidate before handling the focus result, including when
148- // this press leaves the same pane focused.
149- invalidateRejectedLeftPointer(rt, ev)
150- if pl.ID != oldFocus {
182+ invalidateRejectedLeftPointer(ac.overlays, ev)
183+ if pl.ID != focusedID {
151184 d.exitCopyMode(ac)
152185 d.refreshPaneTitleOnFocus(sess, pl.ID)
153186 }
154187 d.invalidateRender(sess, ac, true, "input.go")
155- return
188+ route.handled = true
189+ return route
156190 }
157- var p *pane
158- translated := false
159- hoveredFocused := true
160191 if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {
161192 oldFocus := focusedID
162- layoutChanged := false
163- if isMouseFocusPress(ev) {
164- layoutChanged = focusPlacementLocked(tb, pl.ID)
165- }
166- p = tb.panes[pl.ID]
167- hoveredFocused = pl.ID == oldFocus
193+ shouldFocus := isMouseFocusPress(ev)
194+ p := tb.panes[pl.ID]
195+ hoveredFocused := pl.ID == oldFocus
168196 tb.mu.Unlock()
169- if layoutChanged {
170- d.applyTabLayout(sess, tb)
197+ if shouldFocus {
198+ _, p = d.focusMousePane(sess, ac, tb, pl.ID)
171199 }
172200 if p == nil {
173- invalidateRejectedLeftPointer(rt, ev)
174- return
201+ route.handled = true
202+ return route
175203 }
176- if isMouseFocusPress(ev) && pl.ID != oldFocus {
204+ if shouldFocus && pl.ID != oldFocus {
177205 d.exitCopyMode(ac)
178206 d.refreshPaneTitleOnFocus(sess, pl.ID)
179207 d.invalidateRender(sess, ac, true, "input.go")
180208 }
209+ route.p = p
210+ route.hoveredFocused = hoveredFocused
181211 if multi {
182- ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
183- translated = true
212+ route.event = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
213+ route.translated = true
184214 }
185- } else {
186- if multi {
187- tb.mu.Unlock()
188- invalidateRejectedLeftPointer(rt, ev)
189- return
190- }
191- p = tb.focusedPane()
215+ return route
216+ }
217+ if multi {
192218 tb.mu.Unlock()
193- if p == nil {
194- invalidateRejectedLeftPointer(rt, ev)
195- return
219+ route.handled = true
220+ return route
221+ }
222+ tb.mu.Unlock()
223+ _, route.p = sess.paneForAttachment(ac)
224+ return route
225+}
226+
227+func mouseFocusedPaneIDLocked(ac *attachedClient, tb *tab) layout.PaneID {
228+ if ac != nil && tb != nil {
229+ view := ac.viewSnapshot()
230+ if domain.TabStableID(tb.stableID) == view.tabID {
231+ for _, p := range tb.panes {
232+ if p != nil && domain.PaneStableID(p.stableID) == view.paneID {
233+ return p.id
234+ }
235+ }
196236 }
197237 }
198- if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, p, frameEvent) {
199- return
238+ if tb == nil || tb.tree == nil {
239+ return ""
240+ }
241+ return tb.tree.Focus
242+}
243+
244+// focusMousePane runs inside the caller's session mutation boundary.
245+func (d *Daemon) focusMousePane(sess *session, ac *attachedClient, tb *tab, id layout.PaneID) (bool, *pane) {
246+ var changed bool
247+ var focused *pane
248+ tb.mu.Lock()
249+ if tb.tree != nil {
250+ focused = tb.panes[id]
251+ if focused != nil {
252+ changed = focusPlacementLocked(tb, id)
253+ }
254+ }
255+ tb.mu.Unlock()
256+ if focused == nil {
257+ return false, nil
258+ }
259+ sess.mu.Lock()
260+ sess.setAttachmentPaneLocked(ac, tb, focused)
261+ sess.mu.Unlock()
262+ if changed {
263+ d.applyTabLayout(sess, tb)
200264 }
201- d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)
265+ return changed, focused
202266 }
203267
204268 func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {
@@ -215,18 +279,24 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
215279 if !mouseSGR || ev.Row == 0 || ev.Row > childRows {
216280 return
217281 }
218- if translated {
219- d.writeToPane(sess, p, ev.Raw)
220- } else {
221- d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows))
222- }
282+ _ = sess.runMutation(func() error {
283+ if translated {
284+ d.writeToPane(sess, p, ev.Raw)
285+ } else {
286+ d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows))
287+ }
288+ return nil
289+ })
223290 return
224291 }
225292
226293 switch ev.Button {
227294 case mouse.WheelUp:
228295 if altScreen {
229- d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A"))
296+ _ = sess.runMutation(func() error {
297+ d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A"))
298+ return nil
299+ })
230300 return
231301 }
232302 if !hoveredFocused {
@@ -236,7 +306,10 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
236306 d.copyWheel(sess, ac, -3)
237307 case mouse.WheelDown:
238308 if altScreen {
239- d.writeToPane(sess, p, []byte("\x1b[B\x1b[B\x1b[B"))
309+ _ = sess.runMutation(func() error {
310+ d.writeToPane(sess, p, []byte("\x1b[B\x1b[B\x1b[B"))
311+ return nil
312+ })
240313 }
241314 }
242315 }
@@ -364,14 +437,17 @@ func (h daemonKeyHandler) Forward(data []byte) {
364437 if owned {
365438 defer effect.End()
366439 }
367- tb := sess.tabForAttachment(h.ac)
368- if tb == nil {
369- return
370- }
371- tb.mu.Lock()
372- p := tb.terminalTargetLocked()
373- tb.mu.Unlock()
374- h.d.writeToPane(sess, p, data)
440+ _ = sess.runMutation(func() error {
441+ tb := sess.tabForAttachment(h.ac)
442+ if tb == nil {
443+ return nil
444+ }
445+ tb.mu.Lock()
446+ p := tb.terminalTargetLocked()
447+ tb.mu.Unlock()
448+ h.d.writeToPane(sess, p, data)
449+ return nil
450+ })
375451 }
376452
377453 func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
@@ -383,12 +459,14 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
383459 defer effect.End()
384460 }
385461 runAction := func(request daemonActionRequest) {
386- request.target = resolveDaemonActionTargetForAttachment(sess, h.ac)
387462 runner := h.actions
388463 if runner == nil {
389464 runner = daemonActions{d: h.d}
390465 }
391- if err := runner.Run(request); err != nil {
466+ if err := sess.runMutation(func() error {
467+ request.target = resolveDaemonActionTargetForAttachment(sess, h.ac)
468+ return runner.Run(request)
469+ }); err != nil {
392470 if errors.Is(err, errDaemonActionNoChange) {
393471 return
394472 }
@@ -408,10 +486,13 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
408486 h.d.enterPalette(sess, h.ac)
409487 case keys.ActionJumpAttention:
410488 if !proxiedJumpSearchesOtherSessions(h.ac.proxied) {
411- if idx, ok := oldestAttentionTab(sess); ok && sess.switchAttachmentTab(h.ac, idx) {
412- h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil)
413- h.d.invalidateRender(sess, h.ac, true, "input.go")
414- }
489+ _ = sess.runMutation(func() error {
490+ if idx, ok := oldestAttentionTab(sess); ok && sess.switchAttachmentTabForDispatch(h.ac, idx) {
491+ h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil)
492+ h.d.invalidateRender(sess, h.ac, true, "input.go")
493+ }
494+ return nil
495+ })
415496 return
416497 }
417498 if effect == nil {
@@ -427,7 +508,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
427508 h.d.reportError(sess, err)
428509 }
429510 case keys.ActionToggleFloatingPane:
430- if err := h.d.toggleFloating(sess, h.ac); err != nil {
511+ if err := sess.runMutation(func() error { return h.d.toggleFloating(sess, h.ac) }); err != nil {
431512 h.d.log.Warn("toggle floating pane failed", "err", err)
432513 h.d.reportError(sess, err)
433514 }
@@ -457,10 +538,13 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
457538 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
458539 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
459540 idx := int(action - keys.ActionSwitchTab1)
460- if sess.switchAttachmentTab(h.ac, idx) {
461- h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil)
462- h.d.invalidateRender(sess, h.ac, true, "input.go")
463- }
541+ _ = sess.runMutation(func() error {
542+ if sess.switchAttachmentTabForDispatch(h.ac, idx) {
543+ h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil)
544+ h.d.invalidateRender(sess, h.ac, true, "input.go")
545+ }
546+ return nil
547+ })
464548 }
465549 }
466550
@@ -82,6 +82,10 @@ func (d *Daemon) handleInputForAttachment(token attachmentConnectionToken, data
82 }82 }
83 83
84 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {84 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
85+ d.handleMouseMutation(ac, ev)
86+}
87+
88+func (d *Daemon) handleMouseMutation(ac *attachedClient, ev mouse.Event) {
85 frameEvent := ev89 frameEvent := ev
86 ac.initOverlays()90 ac.initOverlays()
87 rt := ac.overlays91 rt := ac.overlays
@@ -108,97 +112,157 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
108 return112 return
109 }113 }
110 114
111- tb.mu.Lock()115+ // Copy-mode publication deliberately revalidates outside the dispatch lock:
116+ // a concurrent release or pane close must be able to invalidate its candidate.
117+ var routed mouseRoute
118+ _ = sess.runMutation(func() error {
119+ routed = d.routeMouseMutation(sess, ac, ev)
120+ return nil
121+ })
122+ if routed.handled {
123+ return
124+ }
125+ if routed.p == nil {
126+ invalidateRejectedLeftPointer(rt, ev)
127+ return
128+ }
129+ if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, routed.tb, routed.p, frameEvent) {
130+ return
131+ }
132+ d.handleTerminalMouse(sess, ac, routed.p, routed.event, routed.translated, routed.hoveredFocused)
133+}
134+
135+type mouseRoute struct {
136+ tb *tab
137+ p *pane
138+ event mouse.Event
139+ translated bool
140+ hoveredFocused bool
141+ handled bool
142+}
143+
144+func (d *Daemon) routeMouseMutation(sess *session, ac *attachedClient, ev mouse.Event) mouseRoute {
145+ route := mouseRoute{event: ev, hoveredFocused: true}
146+ tb := sess.tabForAttachment(ac)
147+ if tb == nil {
148+ route.handled = true
149+ return route
150+ }
151+ route.tb = tb
112 contentRow := ev.Row - clientTopBarRows152 contentRow := ev.Row - clientTopBarRows
153+ tb.mu.Lock()
113 floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())154 floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
114 if floatingVisible {155 if floatingVisible {
115 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {156 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
116 tb.mu.Unlock()157 tb.mu.Unlock()
117- invalidateRejectedLeftPointer(rt, ev)158+ route.handled = true
118- return159+ return route
119 }160 }
120 tb.mu.Unlock()161 tb.mu.Unlock()
121- if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, floating, frameEvent) {162+ route.p = floating
122- return163+ route.event = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y)
123- }164+ route.translated = true
124- ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y)165+ return route
125- d.handleTerminalMouse(sess, ac, floating, ev, true, true)
126- return
127 }166 }
128 pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)167 pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)
129 multi := len(tb.panes) > 1168 multi := len(tb.panes) > 1
130- focusedID := layout.PaneID("")169+ focusedID := mouseFocusedPaneIDLocked(ac, tb)
131- if tb.tree != nil {
132- focusedID = tb.tree.Focus
133- }
134 if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {170 if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {
135 if !isMouseFocusPress(ev) {171 if !isMouseFocusPress(ev) {
136 tb.mu.Unlock()172 tb.mu.Unlock()
137- invalidateRejectedLeftPointer(rt, ev)173+ route.handled = true
138- return174+ return route
139 }175 }
140- oldFocus := focusedID
141- layoutChanged := focusPlacementLocked(tb, pl.ID)
142 tb.mu.Unlock()176 tb.mu.Unlock()
143- if layoutChanged {177+ _, focused := d.focusMousePane(sess, ac, tb, pl.ID)
144- d.applyTabLayout(sess, tb)178+ if focused == nil {
179+ route.handled = true
180+ return route
145 }181 }
146- // A title bar never routes to terminal content. Clear any pre-existing182+ invalidateRejectedLeftPointer(ac.overlays, ev)
147- // left-button candidate before handling the focus result, including when183+ if pl.ID != focusedID {
148- // this press leaves the same pane focused.
149- invalidateRejectedLeftPointer(rt, ev)
150- if pl.ID != oldFocus {
151 d.exitCopyMode(ac)184 d.exitCopyMode(ac)
152 d.refreshPaneTitleOnFocus(sess, pl.ID)185 d.refreshPaneTitleOnFocus(sess, pl.ID)
153 }186 }
154 d.invalidateRender(sess, ac, true, "input.go")187 d.invalidateRender(sess, ac, true, "input.go")
155- return188+ route.handled = true
189+ return route
156 }190 }
157- var p *pane
158- translated := false
159- hoveredFocused := true
160 if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {191 if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {
161 oldFocus := focusedID192 oldFocus := focusedID
162- layoutChanged := false193+ shouldFocus := isMouseFocusPress(ev)
163- if isMouseFocusPress(ev) {194+ p := tb.panes[pl.ID]
164- layoutChanged = focusPlacementLocked(tb, pl.ID)195+ hoveredFocused := pl.ID == oldFocus
165- }
166- p = tb.panes[pl.ID]
167- hoveredFocused = pl.ID == oldFocus
168 tb.mu.Unlock()196 tb.mu.Unlock()
169- if layoutChanged {197+ if shouldFocus {
170- d.applyTabLayout(sess, tb)198+ _, p = d.focusMousePane(sess, ac, tb, pl.ID)
171 }199 }
172 if p == nil {200 if p == nil {
173- invalidateRejectedLeftPointer(rt, ev)201+ route.handled = true
174- return202+ return route
175 }203 }
176- if isMouseFocusPress(ev) && pl.ID != oldFocus {204+ if shouldFocus && pl.ID != oldFocus {
177 d.exitCopyMode(ac)205 d.exitCopyMode(ac)
178 d.refreshPaneTitleOnFocus(sess, pl.ID)206 d.refreshPaneTitleOnFocus(sess, pl.ID)
179 d.invalidateRender(sess, ac, true, "input.go")207 d.invalidateRender(sess, ac, true, "input.go")
180 }208 }
209+ route.p = p
210+ route.hoveredFocused = hoveredFocused
181 if multi {211 if multi {
182- ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)212+ route.event = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
183- translated = true213+ route.translated = true
184 }214 }
185- } else {215+ return route
186- if multi {216+ }
187- tb.mu.Unlock()217+ if multi {
188- invalidateRejectedLeftPointer(rt, ev)
189- return
190- }
191- p = tb.focusedPane()
192 tb.mu.Unlock()218 tb.mu.Unlock()
193- if p == nil {219+ route.handled = true
194- invalidateRejectedLeftPointer(rt, ev)220+ return route
195- return221+ }
222+ tb.mu.Unlock()
223+ _, route.p = sess.paneForAttachment(ac)
224+ return route
225+}
226+
227+func mouseFocusedPaneIDLocked(ac *attachedClient, tb *tab) layout.PaneID {
228+ if ac != nil && tb != nil {
229+ view := ac.viewSnapshot()
230+ if domain.TabStableID(tb.stableID) == view.tabID {
231+ for _, p := range tb.panes {
232+ if p != nil && domain.PaneStableID(p.stableID) == view.paneID {
233+ return p.id
234+ }
235+ }
196 }236 }
197 }237 }
198- if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, p, frameEvent) {238+ if tb == nil || tb.tree == nil {
199- return239+ return ""
240+ }
241+ return tb.tree.Focus
242+}
243+
244+// focusMousePane runs inside the caller's session mutation boundary.
245+func (d *Daemon) focusMousePane(sess *session, ac *attachedClient, tb *tab, id layout.PaneID) (bool, *pane) {
246+ var changed bool
247+ var focused *pane
248+ tb.mu.Lock()
249+ if tb.tree != nil {
250+ focused = tb.panes[id]
251+ if focused != nil {
252+ changed = focusPlacementLocked(tb, id)
253+ }
254+ }
255+ tb.mu.Unlock()
256+ if focused == nil {
257+ return false, nil
258+ }
259+ sess.mu.Lock()
260+ sess.setAttachmentPaneLocked(ac, tb, focused)
261+ sess.mu.Unlock()
262+ if changed {
263+ d.applyTabLayout(sess, tb)
200 }264 }
201- d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)265+ return changed, focused
202 }266 }
203 267
204 func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {268 func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {
@@ -215,18 +279,24 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
215 if !mouseSGR || ev.Row == 0 || ev.Row > childRows {279 if !mouseSGR || ev.Row == 0 || ev.Row > childRows {
216 return280 return
217 }281 }
218- if translated {282+ _ = sess.runMutation(func() error {
219- d.writeToPane(sess, p, ev.Raw)283+ if translated {
220- } else {284+ d.writeToPane(sess, p, ev.Raw)
221- d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows))285+ } else {
222- }286+ d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows))
287+ }
288+ return nil
289+ })
223 return290 return
224 }291 }
225 292
226 switch ev.Button {293 switch ev.Button {
227 case mouse.WheelUp:294 case mouse.WheelUp:
228 if altScreen {295 if altScreen {
229- d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A"))296+ _ = sess.runMutation(func() error {
297+ d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A"))
298+ return nil
299+ })
230 return300 return
231 }301 }
232 if !hoveredFocused {302 if !hoveredFocused {
@@ -236,7 +306,10 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
236 d.copyWheel(sess, ac, -3)306 d.copyWheel(sess, ac, -3)
237 case mouse.WheelDown:307 case mouse.WheelDown:
238 if altScreen {308 if altScreen {
239- d.writeToPane(sess, p, []byte("\x1b[B\x1b[B\x1b[B"))309+ _ = sess.runMutation(func() error {
310+ d.writeToPane(sess, p, []byte("\x1b[B\x1b[B\x1b[B"))
311+ return nil
312+ })
240 }313 }
241 }314 }
242 }315 }
@@ -364,14 +437,17 @@ func (h daemonKeyHandler) Forward(data []byte) {
364 if owned {437 if owned {
365 defer effect.End()438 defer effect.End()
366 }439 }
367- tb := sess.tabForAttachment(h.ac)440+ _ = sess.runMutation(func() error {
368- if tb == nil {441+ tb := sess.tabForAttachment(h.ac)
369- return442+ if tb == nil {
370- }443+ return nil
371- tb.mu.Lock()444+ }
372- p := tb.terminalTargetLocked()445+ tb.mu.Lock()
373- tb.mu.Unlock()446+ p := tb.terminalTargetLocked()
374- h.d.writeToPane(sess, p, data)447+ tb.mu.Unlock()
448+ h.d.writeToPane(sess, p, data)
449+ return nil
450+ })
375 }451 }
376 452
377 func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {453 func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
@@ -383,12 +459,14 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
383 defer effect.End()459 defer effect.End()
384 }460 }
385 runAction := func(request daemonActionRequest) {461 runAction := func(request daemonActionRequest) {
386- request.target = resolveDaemonActionTargetForAttachment(sess, h.ac)
387 runner := h.actions462 runner := h.actions
388 if runner == nil {463 if runner == nil {
389 runner = daemonActions{d: h.d}464 runner = daemonActions{d: h.d}
390 }465 }
391- if err := runner.Run(request); err != nil {466+ if err := sess.runMutation(func() error {
467+ request.target = resolveDaemonActionTargetForAttachment(sess, h.ac)
468+ return runner.Run(request)
469+ }); err != nil {
392 if errors.Is(err, errDaemonActionNoChange) {470 if errors.Is(err, errDaemonActionNoChange) {
393 return471 return
394 }472 }
@@ -408,10 +486,13 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
408 h.d.enterPalette(sess, h.ac)486 h.d.enterPalette(sess, h.ac)
409 case keys.ActionJumpAttention:487 case keys.ActionJumpAttention:
410 if !proxiedJumpSearchesOtherSessions(h.ac.proxied) {488 if !proxiedJumpSearchesOtherSessions(h.ac.proxied) {
411- if idx, ok := oldestAttentionTab(sess); ok && sess.switchAttachmentTab(h.ac, idx) {489+ _ = sess.runMutation(func() error {
412- h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil)490+ if idx, ok := oldestAttentionTab(sess); ok && sess.switchAttachmentTabForDispatch(h.ac, idx) {
413- h.d.invalidateRender(sess, h.ac, true, "input.go")491+ h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil)
414- }492+ h.d.invalidateRender(sess, h.ac, true, "input.go")
493+ }
494+ return nil
495+ })
415 return496 return
416 }497 }
417 if effect == nil {498 if effect == nil {
@@ -427,7 +508,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
427 h.d.reportError(sess, err)508 h.d.reportError(sess, err)
428 }509 }
429 case keys.ActionToggleFloatingPane:510 case keys.ActionToggleFloatingPane:
430- if err := h.d.toggleFloating(sess, h.ac); err != nil {511+ if err := sess.runMutation(func() error { return h.d.toggleFloating(sess, h.ac) }); err != nil {
431 h.d.log.Warn("toggle floating pane failed", "err", err)512 h.d.log.Warn("toggle floating pane failed", "err", err)
432 h.d.reportError(sess, err)513 h.d.reportError(sess, err)
433 }514 }
@@ -457,10 +538,13 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
457 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,538 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
458 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:539 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
459 idx := int(action - keys.ActionSwitchTab1)540 idx := int(action - keys.ActionSwitchTab1)
460- if sess.switchAttachmentTab(h.ac, idx) {541+ _ = sess.runMutation(func() error {
461- h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil)542+ if sess.switchAttachmentTabForDispatch(h.ac, idx) {
462- h.d.invalidateRender(sess, h.ac, true, "input.go")543+ h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil)
463- }544+ h.d.invalidateRender(sess, h.ac, true, "input.go")
545+ }
546+ return nil
547+ })
464 }548 }
465 }549 }
466 550
M internal/usecase/daemon/input_test.go +53 -0
@@ -27,6 +27,59 @@ import (
2727
2828 // --- test doubles -----------------------------------------------------------
2929
30+type blockingInputActionRunner struct {
31+ admitted chan<- struct{}
32+ release <-chan struct{}
33+ mu *sync.Mutex
34+ order *[]string
35+}
36+
37+func (r *blockingInputActionRunner) Run(daemonActionRequest) error {
38+ close(r.admitted)
39+ <-r.release
40+ r.mu.Lock()
41+ *r.order = append(*r.order, "keyboard")
42+ r.mu.Unlock()
43+ return nil
44+}
45+
46+func TestAttachedKeyboardMutationSharesDispatchBoundaryWithCommand(t *testing.T) {
47+ d := newTestDaemon(t, nil, stubClock{})
48+ sess := addControlSession(d, "work", "t_work", "p_work")
49+ ac := &attachedClient{}
50+ ac.setSession(sess)
51+
52+ var orderMu sync.Mutex
53+ var order []string
54+ admitted := make(chan struct{})
55+ release := make(chan struct{})
56+ runner := &blockingInputActionRunner{admitted: admitted, release: release, mu: &orderMu, order: &order}
57+ handler := daemonKeyHandler{d: d, ac: ac, actions: runner}
58+ keyboardDone := make(chan struct{})
59+ go func() {
60+ handler.Action(keys.ActionGrowPaneWidth, nil)
61+ close(keyboardDone)
62+ }()
63+ <-admitted
64+ require.False(t, sess.dispatchMu.TryLock(), "keyboard mutation must hold the session dispatch boundary")
65+
66+ commandDone := make(chan struct{})
67+ go func() {
68+ _ = sess.runMutation(func() error {
69+ orderMu.Lock()
70+ order = append(order, "command")
71+ orderMu.Unlock()
72+ return nil
73+ })
74+ close(commandDone)
75+ }()
76+ close(release)
77+ <-keyboardDone
78+ <-commandDone
79+
80+ require.Equal(t, []string{"keyboard", "command"}, order)
81+}
82+
3083 func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) {
3184 tests := []struct {
3285 name string
@@ -27,6 +27,59 @@ import (
27 27
28 // --- test doubles -----------------------------------------------------------28 // --- test doubles -----------------------------------------------------------
29 29
30+type blockingInputActionRunner struct {
31+ admitted chan<- struct{}
32+ release <-chan struct{}
33+ mu *sync.Mutex
34+ order *[]string
35+}
36+
37+func (r *blockingInputActionRunner) Run(daemonActionRequest) error {
38+ close(r.admitted)
39+ <-r.release
40+ r.mu.Lock()
41+ *r.order = append(*r.order, "keyboard")
42+ r.mu.Unlock()
43+ return nil
44+}
45+
46+func TestAttachedKeyboardMutationSharesDispatchBoundaryWithCommand(t *testing.T) {
47+ d := newTestDaemon(t, nil, stubClock{})
48+ sess := addControlSession(d, "work", "t_work", "p_work")
49+ ac := &attachedClient{}
50+ ac.setSession(sess)
51+
52+ var orderMu sync.Mutex
53+ var order []string
54+ admitted := make(chan struct{})
55+ release := make(chan struct{})
56+ runner := &blockingInputActionRunner{admitted: admitted, release: release, mu: &orderMu, order: &order}
57+ handler := daemonKeyHandler{d: d, ac: ac, actions: runner}
58+ keyboardDone := make(chan struct{})
59+ go func() {
60+ handler.Action(keys.ActionGrowPaneWidth, nil)
61+ close(keyboardDone)
62+ }()
63+ <-admitted
64+ require.False(t, sess.dispatchMu.TryLock(), "keyboard mutation must hold the session dispatch boundary")
65+
66+ commandDone := make(chan struct{})
67+ go func() {
68+ _ = sess.runMutation(func() error {
69+ orderMu.Lock()
70+ order = append(order, "command")
71+ orderMu.Unlock()
72+ return nil
73+ })
74+ close(commandDone)
75+ }()
76+ close(release)
77+ <-keyboardDone
78+ <-commandDone
79+
80+ require.Equal(t, []string{"keyboard", "command"}, order)
81+}
82+
30 func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) {83 func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) {
31 tests := []struct {84 tests := []struct {
32 name string85 name string
M internal/usecase/daemon/lease_callback_guard_test.go +1 -1
@@ -54,7 +54,7 @@ func TestResizeAndRetryCallbacksRejectSameObjectLeaseReplacement(t *testing.T) {
5454 done := tc.done(t, rc)
5555 require.NotNil(t, done)
5656
57- rc.notePark(ac)
57+ rc.noteDetach(ac)
5858 rc.attach(ac) // resume the exact same object under a new incarnation
5959 timer.ch <- time.Time{}
6060 awaitTestCompletion(t, done, "stale callback did not complete")
@@ -54,7 +54,7 @@ func TestResizeAndRetryCallbacksRejectSameObjectLeaseReplacement(t *testing.T) {
54 done := tc.done(t, rc)54 done := tc.done(t, rc)
55 require.NotNil(t, done)55 require.NotNil(t, done)
56 56
57- rc.notePark(ac)57+ rc.noteDetach(ac)
58 rc.attach(ac) // resume the exact same object under a new incarnation58 rc.attach(ac) // resume the exact same object under a new incarnation
59 timer.ch <- time.Time{}59 timer.ch <- time.Time{}
60 awaitTestCompletion(t, done, "stale callback did not complete")60 awaitTestCompletion(t, done, "stale callback did not complete")
M internal/usecase/daemon/multi_attachment_callers_test.go +91 -0
@@ -1,9 +1,11 @@
11 package daemon
22
33 import (
4+ "encoding/json"
45 "testing"
56
67 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/layout"
79 "github.com/stretchr/testify/require"
810 )
911
@@ -21,3 +23,92 @@ func TestDaemonActionTargetsUseEachInitiatingAttachmentView(t *testing.T) {
2123 require.Same(t, sess.tabs[0].focusedPane(), firstTarget.pane)
2224 require.Same(t, sess.tabs[1].focusedPane(), secondTarget.pane)
2325 }
26+
27+func TestAttachmentsKeepDifferentPaneFocusForActionsRenderingAndMetadata(t *testing.T) {
28+ sess, firstPane, secondPane := multiPaneAttachmentSession(t)
29+ first, second := registryTestAttachment(1), registryTestAttachment(2)
30+ require.True(t, sess.registerAttachment(first))
31+ require.True(t, sess.registerAttachment(second))
32+ require.True(t, sess.updateAttachmentView(second, func(view *attachmentView) {
33+ view.paneID = domain.PaneStableID(secondPane.stableID)
34+ }))
35+
36+ firstTarget := resolveDaemonActionTargetForAttachment(sess, first)
37+ secondTarget := resolveDaemonActionTargetForAttachment(sess, second)
38+ require.Same(t, firstPane, firstTarget.pane)
39+ require.Same(t, secondPane, secondTarget.pane)
40+
41+ firstState, ok := captureLocalRenderState(sess, first, renderCaptureRequest{})
42+ require.True(t, ok)
43+ secondState, ok := captureLocalRenderState(sess, second, renderCaptureRequest{})
44+ require.True(t, ok)
45+ require.Equal(t, firstPane.id, firstState.layout.focus)
46+ require.Equal(t, secondPane.id, secondState.layout.focus)
47+
48+ d := newTestDaemon(t, nil, stubClock{})
49+ var rows []struct {
50+ Pane string `json:"pane"`
51+ Focused bool `json:"focused"`
52+ }
53+ output, err := (controlExec{d: d, sess: sess, tab: sess.tabs[0], target: daemonActionTarget{attachment: second}}).ListPanes(true)
54+ require.NoError(t, err)
55+ require.NoError(t, json.Unmarshal([]byte(output), &rows))
56+ for _, row := range rows {
57+ require.Equal(t, row.Pane == string(secondPane.id), row.Focused)
58+ }
59+}
60+
61+func TestAttachmentFocusActionUpdatesOnlyInitiatingPaneView(t *testing.T) {
62+ sess, firstPane, secondPane := multiPaneAttachmentSession(t)
63+ first, second := registryTestAttachment(1), registryTestAttachment(2)
64+ require.True(t, sess.registerAttachment(first))
65+ require.True(t, sess.registerAttachment(second))
66+
67+ d := newTestDaemon(t, nil, stubClock{})
68+ require.NoError(t, d.focusDir(sess, second, layout.Right, nil))
69+ require.Equal(t, domain.PaneStableID(firstPane.stableID), first.viewSnapshot().paneID)
70+ require.Equal(t, domain.PaneStableID(secondPane.stableID), second.viewSnapshot().paneID)
71+}
72+
73+func TestPaneRemovalRepairsEachAttachmentViewIndependently(t *testing.T) {
74+ sess, firstPane, secondPane := multiPaneAttachmentSession(t)
75+ first, second := registryTestAttachment(1), registryTestAttachment(2)
76+ require.True(t, sess.registerAttachment(first))
77+ require.True(t, sess.registerAttachment(second))
78+ require.True(t, sess.updateAttachmentView(second, func(view *attachmentView) {
79+ view.paneID = domain.PaneStableID(secondPane.stableID)
80+ }))
81+
82+ require.NoError(t, sess.runMutation(func() error {
83+ sess.mu.Lock()
84+ defer sess.mu.Unlock()
85+ tb := sess.tabs[0]
86+ tb.mu.Lock()
87+ require.NoError(t, tb.tree.Close(firstPane.id))
88+ delete(tb.panes, firstPane.id)
89+ tb.bumpLayoutGenerationLocked()
90+ tb.mu.Unlock()
91+ sess.repairAttachmentViewsLocked()
92+ return nil
93+ }))
94+
95+ require.Equal(t, domain.PaneStableID(secondPane.stableID), first.viewSnapshot().paneID)
96+ require.Equal(t, domain.PaneStableID(secondPane.stableID), second.viewSnapshot().paneID)
97+}
98+
99+func multiPaneAttachmentSession(t *testing.T) (*session, *pane, *pane) {
100+ t.Helper()
101+ sess := registryTestSession()
102+ tb := sess.tabs[0]
103+ firstPane := tb.panes[layout.PaneID("pane-1")]
104+ secondPane := newPaneWithStableID("pane-2", "stable-pane-b", nil, domain.Size{Cols: 80, Rows: 23})
105+ tb.mu.Lock()
106+ tb.panes[secondPane.id] = secondPane
107+ tb.tree = &layout.Tree{
108+ Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(firstPane.id), layout.NewLeaf(secondPane.id)}},
109+ Focus: firstPane.id,
110+ }
111+ tb.bumpLayoutGenerationLocked()
112+ tb.mu.Unlock()
113+ return sess, firstPane, secondPane
114+}
@@ -1,9 +1,11 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "encoding/json"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/layout"
7 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
8 )10 )
9 11
@@ -21,3 +23,92 @@ func TestDaemonActionTargetsUseEachInitiatingAttachmentView(t *testing.T) {
21 require.Same(t, sess.tabs[0].focusedPane(), firstTarget.pane)23 require.Same(t, sess.tabs[0].focusedPane(), firstTarget.pane)
22 require.Same(t, sess.tabs[1].focusedPane(), secondTarget.pane)24 require.Same(t, sess.tabs[1].focusedPane(), secondTarget.pane)
23 }25 }
26+
27+func TestAttachmentsKeepDifferentPaneFocusForActionsRenderingAndMetadata(t *testing.T) {
28+ sess, firstPane, secondPane := multiPaneAttachmentSession(t)
29+ first, second := registryTestAttachment(1), registryTestAttachment(2)
30+ require.True(t, sess.registerAttachment(first))
31+ require.True(t, sess.registerAttachment(second))
32+ require.True(t, sess.updateAttachmentView(second, func(view *attachmentView) {
33+ view.paneID = domain.PaneStableID(secondPane.stableID)
34+ }))
35+
36+ firstTarget := resolveDaemonActionTargetForAttachment(sess, first)
37+ secondTarget := resolveDaemonActionTargetForAttachment(sess, second)
38+ require.Same(t, firstPane, firstTarget.pane)
39+ require.Same(t, secondPane, secondTarget.pane)
40+
41+ firstState, ok := captureLocalRenderState(sess, first, renderCaptureRequest{})
42+ require.True(t, ok)
43+ secondState, ok := captureLocalRenderState(sess, second, renderCaptureRequest{})
44+ require.True(t, ok)
45+ require.Equal(t, firstPane.id, firstState.layout.focus)
46+ require.Equal(t, secondPane.id, secondState.layout.focus)
47+
48+ d := newTestDaemon(t, nil, stubClock{})
49+ var rows []struct {
50+ Pane string `json:"pane"`
51+ Focused bool `json:"focused"`
52+ }
53+ output, err := (controlExec{d: d, sess: sess, tab: sess.tabs[0], target: daemonActionTarget{attachment: second}}).ListPanes(true)
54+ require.NoError(t, err)
55+ require.NoError(t, json.Unmarshal([]byte(output), &rows))
56+ for _, row := range rows {
57+ require.Equal(t, row.Pane == string(secondPane.id), row.Focused)
58+ }
59+}
60+
61+func TestAttachmentFocusActionUpdatesOnlyInitiatingPaneView(t *testing.T) {
62+ sess, firstPane, secondPane := multiPaneAttachmentSession(t)
63+ first, second := registryTestAttachment(1), registryTestAttachment(2)
64+ require.True(t, sess.registerAttachment(first))
65+ require.True(t, sess.registerAttachment(second))
66+
67+ d := newTestDaemon(t, nil, stubClock{})
68+ require.NoError(t, d.focusDir(sess, second, layout.Right, nil))
69+ require.Equal(t, domain.PaneStableID(firstPane.stableID), first.viewSnapshot().paneID)
70+ require.Equal(t, domain.PaneStableID(secondPane.stableID), second.viewSnapshot().paneID)
71+}
72+
73+func TestPaneRemovalRepairsEachAttachmentViewIndependently(t *testing.T) {
74+ sess, firstPane, secondPane := multiPaneAttachmentSession(t)
75+ first, second := registryTestAttachment(1), registryTestAttachment(2)
76+ require.True(t, sess.registerAttachment(first))
77+ require.True(t, sess.registerAttachment(second))
78+ require.True(t, sess.updateAttachmentView(second, func(view *attachmentView) {
79+ view.paneID = domain.PaneStableID(secondPane.stableID)
80+ }))
81+
82+ require.NoError(t, sess.runMutation(func() error {
83+ sess.mu.Lock()
84+ defer sess.mu.Unlock()
85+ tb := sess.tabs[0]
86+ tb.mu.Lock()
87+ require.NoError(t, tb.tree.Close(firstPane.id))
88+ delete(tb.panes, firstPane.id)
89+ tb.bumpLayoutGenerationLocked()
90+ tb.mu.Unlock()
91+ sess.repairAttachmentViewsLocked()
92+ return nil
93+ }))
94+
95+ require.Equal(t, domain.PaneStableID(secondPane.stableID), first.viewSnapshot().paneID)
96+ require.Equal(t, domain.PaneStableID(secondPane.stableID), second.viewSnapshot().paneID)
97+}
98+
99+func multiPaneAttachmentSession(t *testing.T) (*session, *pane, *pane) {
100+ t.Helper()
101+ sess := registryTestSession()
102+ tb := sess.tabs[0]
103+ firstPane := tb.panes[layout.PaneID("pane-1")]
104+ secondPane := newPaneWithStableID("pane-2", "stable-pane-b", nil, domain.Size{Cols: 80, Rows: 23})
105+ tb.mu.Lock()
106+ tb.panes[secondPane.id] = secondPane
107+ tb.tree = &layout.Tree{
108+ Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(firstPane.id), layout.NewLeaf(secondPane.id)}},
109+ Focus: firstPane.id,
110+ }
111+ tb.bumpLayoutGenerationLocked()
112+ tb.mu.Unlock()
113+ return sess, firstPane, secondPane
114+}
M internal/usecase/daemon/pane_actions.go +75 -14
@@ -195,14 +195,10 @@ func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
195195 if tb == nil {
196196 return layout.ErrNotFound
197197 }
198- tb.mu.Lock()
199- if tb.tree == nil {
200- tb.mu.Unlock()
198+ if target.pane == nil {
201199 return layout.ErrNotFound
202200 }
203- id := tb.tree.Focus
204- tb.mu.Unlock()
205- return d.closePane(sess, tb, id, ac, true)
201+ return d.closePane(sess, tb, target.pane.id, ac, true)
206202 }
207203
208204 // reapPane retains the explicit-owner entry point for focused close tests. PTY
@@ -374,7 +370,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
374370 if target.pane != nil {
375371 oldFocus = target.pane.id
376372 }
377- span, err := d.focusDirAt(sess, target.tab, target.pane, dir)
373+ span, err := d.focusDirAt(sess, target.tab, target.pane, dir, ac)
378374 if err == nil {
379375 if ac != nil {
380376 d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")
@@ -419,11 +415,48 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
419415 // successful directional focus move. Keep copy-mode exit before the optional
420416 // stack title refresh, and both before render invalidation.
421417 func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) {
422- tb.mu.Lock()
423- newFocus := tb.tree.Focus
424- pl, hasPlacement := focusedPlacementLocked(tb)
425- tb.mu.Unlock()
426- if newFocus != oldFocus {
418+ var newFocus layout.PaneID
419+ var newPane *pane
420+ var pl layout.Placement
421+ var hasPlacement bool
422+ beforePaneID := domain.PaneStableID("")
423+ if ac != nil {
424+ beforePaneID = ac.viewSnapshot().paneID
425+ }
426+ if tb != nil {
427+ tb.mu.Lock()
428+ if ac != nil {
429+ view := ac.viewSnapshot()
430+ if domain.TabStableID(tb.stableID) == view.tabID {
431+ for _, candidate := range tb.panes {
432+ if candidate != nil && domain.PaneStableID(candidate.stableID) == view.paneID {
433+ newPane = candidate
434+ break
435+ }
436+ }
437+ }
438+ } else {
439+ newPane = tb.focusedPane()
440+ if newPane != nil {
441+ newFocus = newPane.id
442+ }
443+ }
444+ if newPane != nil {
445+ newFocus = newPane.id
446+ pl, hasPlacement = panePlacementLocked(tb, newFocus)
447+ }
448+ tb.mu.Unlock()
449+ }
450+
451+ moved := newFocus != oldFocus
452+ if ac != nil && newPane != nil {
453+ sess.mu.Lock()
454+ sess.setAttachmentPaneLocked(ac, tb, newPane)
455+ sess.mu.Unlock()
456+ after := ac.viewSnapshot()
457+ moved = beforePaneID != after.paneID
458+ }
459+ if moved {
427460 d.exitCopyMode(ac)
428461 if hasPlacement && pl.InStack {
429462 d.refreshPaneTitleOnFocus(sess, newFocus)
@@ -432,10 +465,27 @@ func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb
432465 d.invalidateRender(sess, ac, true, producer)
433466 }
434467
435-func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) (domain.Rect, error) {
468+func panePlacementLocked(tb *tab, id layout.PaneID) (layout.Placement, bool) {
469+ placements, ok := solvedPlacementsLocked(tb)
470+ if !ok {
471+ return layout.Placement{}, false
472+ }
473+ for _, placement := range placements {
474+ if placement.ID == id {
475+ return placement, true
476+ }
477+ }
478+ return layout.Placement{}, false
479+}
480+
481+func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction, initiating ...*attachedClient) (domain.Rect, error) {
436482 if tb == nil || target == nil {
437483 return domain.Rect{}, layout.ErrNotFound
438484 }
485+ var ac *attachedClient
486+ if len(initiating) > 0 {
487+ ac = initiating[0]
488+ }
439489 tb.mu.Lock()
440490 if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
441491 tb.mu.Unlock()
@@ -446,13 +496,24 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir
446496 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
447497 span, _ := candidate.FocusSpan(area)
448498 err := candidate.FocusDir(dir, area)
449- committed := err == nil && candidate.Focus != tb.tree.Focus
499+ currentFocus := tb.focusedPane()
500+ committed := err == nil && currentFocus != nil && candidate.Focus != currentFocus.id
501+ var focusedPane *pane
450502 if committed {
503+ focusedPane = tb.panes[candidate.Focus]
451504 tb.tree = candidate
452505 tb.bumpLayoutGenerationLocked()
453506 }
454507 tb.mu.Unlock()
455508 if committed {
509+ if ac != nil {
510+ d.exitCopyMode(ac)
511+ }
512+ if sess != nil && ac != nil && focusedPane != nil {
513+ sess.mu.Lock()
514+ sess.setAttachmentPaneLocked(ac, tb, focusedPane)
515+ sess.mu.Unlock()
516+ }
456517 d.applyTabLayout(sess, tb)
457518 }
458519 if errors.Is(err, layout.ErrNoPane) {
@@ -195,14 +195,10 @@ func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
195 if tb == nil {195 if tb == nil {
196 return layout.ErrNotFound196 return layout.ErrNotFound
197 }197 }
198- tb.mu.Lock()198+ if target.pane == nil {
199- if tb.tree == nil {
200- tb.mu.Unlock()
201 return layout.ErrNotFound199 return layout.ErrNotFound
202 }200 }
203- id := tb.tree.Focus201+ return d.closePane(sess, tb, target.pane.id, ac, true)
204- tb.mu.Unlock()
205- return d.closePane(sess, tb, id, ac, true)
206 }202 }
207 203
208 // reapPane retains the explicit-owner entry point for focused close tests. PTY204 // reapPane retains the explicit-owner entry point for focused close tests. PTY
@@ -374,7 +370,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
374 if target.pane != nil {370 if target.pane != nil {
375 oldFocus = target.pane.id371 oldFocus = target.pane.id
376 }372 }
377- span, err := d.focusDirAt(sess, target.tab, target.pane, dir)373+ span, err := d.focusDirAt(sess, target.tab, target.pane, dir, ac)
378 if err == nil {374 if err == nil {
379 if ac != nil {375 if ac != nil {
380 d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")376 d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")
@@ -419,11 +415,48 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
419 // successful directional focus move. Keep copy-mode exit before the optional415 // successful directional focus move. Keep copy-mode exit before the optional
420 // stack title refresh, and both before render invalidation.416 // stack title refresh, and both before render invalidation.
421 func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) {417 func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) {
422- tb.mu.Lock()418+ var newFocus layout.PaneID
423- newFocus := tb.tree.Focus419+ var newPane *pane
424- pl, hasPlacement := focusedPlacementLocked(tb)420+ var pl layout.Placement
425- tb.mu.Unlock()421+ var hasPlacement bool
426- if newFocus != oldFocus {422+ beforePaneID := domain.PaneStableID("")
423+ if ac != nil {
424+ beforePaneID = ac.viewSnapshot().paneID
425+ }
426+ if tb != nil {
427+ tb.mu.Lock()
428+ if ac != nil {
429+ view := ac.viewSnapshot()
430+ if domain.TabStableID(tb.stableID) == view.tabID {
431+ for _, candidate := range tb.panes {
432+ if candidate != nil && domain.PaneStableID(candidate.stableID) == view.paneID {
433+ newPane = candidate
434+ break
435+ }
436+ }
437+ }
438+ } else {
439+ newPane = tb.focusedPane()
440+ if newPane != nil {
441+ newFocus = newPane.id
442+ }
443+ }
444+ if newPane != nil {
445+ newFocus = newPane.id
446+ pl, hasPlacement = panePlacementLocked(tb, newFocus)
447+ }
448+ tb.mu.Unlock()
449+ }
450+
451+ moved := newFocus != oldFocus
452+ if ac != nil && newPane != nil {
453+ sess.mu.Lock()
454+ sess.setAttachmentPaneLocked(ac, tb, newPane)
455+ sess.mu.Unlock()
456+ after := ac.viewSnapshot()
457+ moved = beforePaneID != after.paneID
458+ }
459+ if moved {
427 d.exitCopyMode(ac)460 d.exitCopyMode(ac)
428 if hasPlacement && pl.InStack {461 if hasPlacement && pl.InStack {
429 d.refreshPaneTitleOnFocus(sess, newFocus)462 d.refreshPaneTitleOnFocus(sess, newFocus)
@@ -432,10 +465,27 @@ func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb
432 d.invalidateRender(sess, ac, true, producer)465 d.invalidateRender(sess, ac, true, producer)
433 }466 }
434 467
435-func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) (domain.Rect, error) {468+func panePlacementLocked(tb *tab, id layout.PaneID) (layout.Placement, bool) {
469+ placements, ok := solvedPlacementsLocked(tb)
470+ if !ok {
471+ return layout.Placement{}, false
472+ }
473+ for _, placement := range placements {
474+ if placement.ID == id {
475+ return placement, true
476+ }
477+ }
478+ return layout.Placement{}, false
479+}
480+
481+func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction, initiating ...*attachedClient) (domain.Rect, error) {
436 if tb == nil || target == nil {482 if tb == nil || target == nil {
437 return domain.Rect{}, layout.ErrNotFound483 return domain.Rect{}, layout.ErrNotFound
438 }484 }
485+ var ac *attachedClient
486+ if len(initiating) > 0 {
487+ ac = initiating[0]
488+ }
439 tb.mu.Lock()489 tb.mu.Lock()
440 if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {490 if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
441 tb.mu.Unlock()491 tb.mu.Unlock()
@@ -446,13 +496,24 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir
446 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}496 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
447 span, _ := candidate.FocusSpan(area)497 span, _ := candidate.FocusSpan(area)
448 err := candidate.FocusDir(dir, area)498 err := candidate.FocusDir(dir, area)
449- committed := err == nil && candidate.Focus != tb.tree.Focus499+ currentFocus := tb.focusedPane()
500+ committed := err == nil && currentFocus != nil && candidate.Focus != currentFocus.id
501+ var focusedPane *pane
450 if committed {502 if committed {
503+ focusedPane = tb.panes[candidate.Focus]
451 tb.tree = candidate504 tb.tree = candidate
452 tb.bumpLayoutGenerationLocked()505 tb.bumpLayoutGenerationLocked()
453 }506 }
454 tb.mu.Unlock()507 tb.mu.Unlock()
455 if committed {508 if committed {
509+ if ac != nil {
510+ d.exitCopyMode(ac)
511+ }
512+ if sess != nil && ac != nil && focusedPane != nil {
513+ sess.mu.Lock()
514+ sess.setAttachmentPaneLocked(ac, tb, focusedPane)
515+ sess.mu.Unlock()
516+ }
456 d.applyTabLayout(sess, tb)517 d.applyTabLayout(sess, tb)
457 }518 }
458 if errors.Is(err, layout.ErrNoPane) {519 if errors.Is(err, layout.ErrNoPane) {
M internal/usecase/daemon/proxied_contract_test.go +1 -1
@@ -129,7 +129,7 @@ func TestProxiedRemotePaintSendsMetadataBeforeScreen(t *testing.T) {
129129 require.True(t, applyTestScreenText(proxy, 0, 0, "remote"))
130130
131131 ac.sendMu.Lock()
132- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true})
132+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
133133 require.True(t, ok)
134134 composed := composeFrame(*state, composeCacheInput{})
135135 require.True(t, d.emitFrame(proxy, ac, state, composed))
@@ -129,7 +129,7 @@ func TestProxiedRemotePaintSendsMetadataBeforeScreen(t *testing.T) {
129 require.True(t, applyTestScreenText(proxy, 0, 0, "remote"))129 require.True(t, applyTestScreenText(proxy, 0, 0, "remote"))
130 130
131 ac.sendMu.Lock()131 ac.sendMu.Lock()
132- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true})132+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
133 require.True(t, ok)133 require.True(t, ok)
134 composed := composeFrame(*state, composeCacheInput{})134 composed := composeFrame(*state, composeCacheInput{})
135 require.True(t, d.emitFrame(proxy, ac, state, composed))135 require.True(t, d.emitFrame(proxy, ac, state, composed))
M internal/usecase/daemon/proxy_render.go +4 -4
@@ -17,11 +17,11 @@ type proxyCapture struct {
1717 cursor capturedCursorInputs
1818 }
1919
20-// capturePrimary snapshots the remote VT while holding only proxy.mu. The
20+// captureRenderState snapshots the remote VT while holding only proxy.mu. The
2121 // snapshot is then composed by the ordinary local frame pipeline, which owns
2222 // the bars, overlays, cursor, and renderer shadow. Remote ANSI is therefore
2323 // never handed to a renderer.
24-func (p *proxySession) capturePrimary(ac *attachedClient, request primaryCaptureRequest) (*capturedRenderState, bool) {
24+func (p *proxySession) captureRenderState(ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
2525 if p == nil || ac == nil {
2626 return nil, false
2727 }
@@ -35,7 +35,7 @@ func (p *proxySession) capturePrimary(ac *attachedClient, request primaryCapture
3535 capture := &ac.proxyCapture
3636 damage := screen.CaptureDamage()
3737 if capture.screen != screen {
38- // A replacement screen has no relationship to the retained frame.
38+ // A new screen has no relationship to the retained frame.
3939 capture.frame = renderer.Frame{}
4040 }
4141 if capture.screen != screen || capture.generation != damage.Generation || capture.frame.Validate() != nil {
@@ -152,7 +152,7 @@ func (p *proxySession) resize(size domain.Size) (changed, sent bool) {
152152 p.screen.ResizePlaceholder(content)
153153 p.contentSize = content
154154 // A resize invalidates the local replay chain, but the last applied state
155- // remains the moving snapshot floor for the replacement geometry.
155+ // remains the moving snapshot floor for the new geometry.
156156 p.screenReady = false
157157 p.resetRequested = false
158158 p.screen.stateNum = 0
@@ -17,11 +17,11 @@ type proxyCapture struct {
17 cursor capturedCursorInputs17 cursor capturedCursorInputs
18 }18 }
19 19
20-// capturePrimary snapshots the remote VT while holding only proxy.mu. The20+// captureRenderState snapshots the remote VT while holding only proxy.mu. The
21 // snapshot is then composed by the ordinary local frame pipeline, which owns21 // snapshot is then composed by the ordinary local frame pipeline, which owns
22 // the bars, overlays, cursor, and renderer shadow. Remote ANSI is therefore22 // the bars, overlays, cursor, and renderer shadow. Remote ANSI is therefore
23 // never handed to a renderer.23 // never handed to a renderer.
24-func (p *proxySession) capturePrimary(ac *attachedClient, request primaryCaptureRequest) (*capturedRenderState, bool) {24+func (p *proxySession) captureRenderState(ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
25 if p == nil || ac == nil {25 if p == nil || ac == nil {
26 return nil, false26 return nil, false
27 }27 }
@@ -35,7 +35,7 @@ func (p *proxySession) capturePrimary(ac *attachedClient, request primaryCapture
35 capture := &ac.proxyCapture35 capture := &ac.proxyCapture
36 damage := screen.CaptureDamage()36 damage := screen.CaptureDamage()
37 if capture.screen != screen {37 if capture.screen != screen {
38- // A replacement screen has no relationship to the retained frame.38+ // A new screen has no relationship to the retained frame.
39 capture.frame = renderer.Frame{}39 capture.frame = renderer.Frame{}
40 }40 }
41 if capture.screen != screen || capture.generation != damage.Generation || capture.frame.Validate() != nil {41 if capture.screen != screen || capture.generation != damage.Generation || capture.frame.Validate() != nil {
@@ -152,7 +152,7 @@ func (p *proxySession) resize(size domain.Size) (changed, sent bool) {
152 p.screen.ResizePlaceholder(content)152 p.screen.ResizePlaceholder(content)
153 p.contentSize = content153 p.contentSize = content
154 // A resize invalidates the local replay chain, but the last applied state154 // A resize invalidates the local replay chain, but the last applied state
155- // remains the moving snapshot floor for the replacement geometry.155+ // remains the moving snapshot floor for the new geometry.
156 p.screenReady = false156 p.screenReady = false
157 p.resetRequested = false157 p.resetRequested = false
158 p.screen.stateNum = 0158 p.screen.stateNum = 0
M internal/usecase/daemon/proxy_render_test.go +6 -6
@@ -25,7 +25,7 @@ func TestProxyCaptureRetainsOwnedFrameAcrossSafeDamage(t *testing.T) {
2525 proxy.mu.Unlock()
2626
2727 ac := &attachedClient{}
28- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{})
28+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{})
2929 require.True(t, ok)
3030 require.Equal(t, "abcdefghijkl", proxyFrameText(state.panes[0].frame))
3131 commitDamageReceipts(state.receipts)
@@ -43,7 +43,7 @@ func TestProxyCaptureRetainsOwnedFrameAcrossSafeDamage(t *testing.T) {
4343 proxy.mu.Unlock()
4444
4545 cellsBefore := &ac.proxyCapture.frame.Cells[0]
46- state, ok = proxy.capturePrimary(ac, primaryCaptureRequest{})
46+ state, ok = proxy.captureRenderState(ac, renderCaptureRequest{})
4747 require.True(t, ok)
4848 require.Equal(t, "abcdeZghijkl", proxyFrameText(state.panes[0].frame))
4949 require.Equal(t, cellsBefore, &ac.proxyCapture.frame.Cells[0], "safe span damage must update the retained frame")
@@ -59,7 +59,7 @@ func TestProxyRenderComposesRemoteVTUnderExactlyOneLocalChrome(t *testing.T) {
5959 changed := applyTestScreenText(proxy, 1, 2, "remote")
6060 require.True(t, changed)
6161
62- state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{
62+ state, ok := proxy.captureRenderState(&attachedClient{}, renderCaptureRequest{
6363 bars: barState{status: proxy.statusSegments(false)},
6464 reset: true,
6565 })
@@ -269,7 +269,7 @@ func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) {
269269 default:
270270 }
271271
272- state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{reset: true})
272+ state, ok := proxy.captureRenderState(&attachedClient{}, renderCaptureRequest{reset: true})
273273 require.True(t, ok)
274274 composed := composeFrame(*state, composeCacheInput{})
275275 require.Equal(t, 10, composed.frame.Height, "local viewport remains full-sized")
@@ -318,7 +318,7 @@ func TestProxyStructuredSendFailureRetainsProxyDamage(t *testing.T) {
318318 require.True(t, applyTestScreenText(proxy, 0, 0, "pending"))
319319
320320 ac.sendMu.Lock()
321- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true})
321+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
322322 require.True(t, ok)
323323 composed := composeFrame(*state, composeCacheInput{})
324324 require.True(t, d.emitFrame(proxy, ac, state, composed))
@@ -373,7 +373,7 @@ func TestProxyPrepareFailureRepaintsWithoutLocalSession(t *testing.T) {
373373 proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport())
374374
375375 ac.sendMu.Lock() // emitFrame releases the transaction lock.
376- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{bars: barState{status: proxy.statusSegments(false)}, reset: true})
376+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{bars: barState{status: proxy.statusSegments(false)}, reset: true})
377377 require.True(t, ok)
378378 composed := composeFrame(*state, composeCacheInput{})
379379 // A malformed frame is what makes outputStateStream.prepare fail.
@@ -25,7 +25,7 @@ func TestProxyCaptureRetainsOwnedFrameAcrossSafeDamage(t *testing.T) {
25 proxy.mu.Unlock()25 proxy.mu.Unlock()
26 26
27 ac := &attachedClient{}27 ac := &attachedClient{}
28- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{})28+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{})
29 require.True(t, ok)29 require.True(t, ok)
30 require.Equal(t, "abcdefghijkl", proxyFrameText(state.panes[0].frame))30 require.Equal(t, "abcdefghijkl", proxyFrameText(state.panes[0].frame))
31 commitDamageReceipts(state.receipts)31 commitDamageReceipts(state.receipts)
@@ -43,7 +43,7 @@ func TestProxyCaptureRetainsOwnedFrameAcrossSafeDamage(t *testing.T) {
43 proxy.mu.Unlock()43 proxy.mu.Unlock()
44 44
45 cellsBefore := &ac.proxyCapture.frame.Cells[0]45 cellsBefore := &ac.proxyCapture.frame.Cells[0]
46- state, ok = proxy.capturePrimary(ac, primaryCaptureRequest{})46+ state, ok = proxy.captureRenderState(ac, renderCaptureRequest{})
47 require.True(t, ok)47 require.True(t, ok)
48 require.Equal(t, "abcdeZghijkl", proxyFrameText(state.panes[0].frame))48 require.Equal(t, "abcdeZghijkl", proxyFrameText(state.panes[0].frame))
49 require.Equal(t, cellsBefore, &ac.proxyCapture.frame.Cells[0], "safe span damage must update the retained frame")49 require.Equal(t, cellsBefore, &ac.proxyCapture.frame.Cells[0], "safe span damage must update the retained frame")
@@ -59,7 +59,7 @@ func TestProxyRenderComposesRemoteVTUnderExactlyOneLocalChrome(t *testing.T) {
59 changed := applyTestScreenText(proxy, 1, 2, "remote")59 changed := applyTestScreenText(proxy, 1, 2, "remote")
60 require.True(t, changed)60 require.True(t, changed)
61 61
62- state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{62+ state, ok := proxy.captureRenderState(&attachedClient{}, renderCaptureRequest{
63 bars: barState{status: proxy.statusSegments(false)},63 bars: barState{status: proxy.statusSegments(false)},
64 reset: true,64 reset: true,
65 })65 })
@@ -269,7 +269,7 @@ func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) {
269 default:269 default:
270 }270 }
271 271
272- state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{reset: true})272+ state, ok := proxy.captureRenderState(&attachedClient{}, renderCaptureRequest{reset: true})
273 require.True(t, ok)273 require.True(t, ok)
274 composed := composeFrame(*state, composeCacheInput{})274 composed := composeFrame(*state, composeCacheInput{})
275 require.Equal(t, 10, composed.frame.Height, "local viewport remains full-sized")275 require.Equal(t, 10, composed.frame.Height, "local viewport remains full-sized")
@@ -318,7 +318,7 @@ func TestProxyStructuredSendFailureRetainsProxyDamage(t *testing.T) {
318 require.True(t, applyTestScreenText(proxy, 0, 0, "pending"))318 require.True(t, applyTestScreenText(proxy, 0, 0, "pending"))
319 319
320 ac.sendMu.Lock()320 ac.sendMu.Lock()
321- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true})321+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
322 require.True(t, ok)322 require.True(t, ok)
323 composed := composeFrame(*state, composeCacheInput{})323 composed := composeFrame(*state, composeCacheInput{})
324 require.True(t, d.emitFrame(proxy, ac, state, composed))324 require.True(t, d.emitFrame(proxy, ac, state, composed))
@@ -373,7 +373,7 @@ func TestProxyPrepareFailureRepaintsWithoutLocalSession(t *testing.T) {
373 proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport())373 proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport())
374 374
375 ac.sendMu.Lock() // emitFrame releases the transaction lock.375 ac.sendMu.Lock() // emitFrame releases the transaction lock.
376- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{bars: barState{status: proxy.statusSegments(false)}, reset: true})376+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{bars: barState{status: proxy.statusSegments(false)}, reset: true})
377 require.True(t, ok)377 require.True(t, ok)
378 composed := composeFrame(*state, composeCacheInput{})378 composed := composeFrame(*state, composeCacheInput{})
379 // A malformed frame is what makes outputStateStream.prepare fail.379 // A malformed frame is what makes outputStateStream.prepare fail.
M internal/usecase/daemon/render.go +2 -2
@@ -304,7 +304,7 @@ func (b *runtimeMarkBatch) flush() {
304304 func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {
305305 // Local sessions retain their tab/PTY preparation below. Proxy sessions
306306 // enter the same attachment pipeline but supply their VT snapshot through
307- // attachmentSession.capturePrimary.
307+ // attachmentSession.captureRenderState.
308308 sess, local := localSession(entry)
309309 marks := d.newRuntimeMarkBatch()
310310 if lease != nil {
@@ -417,7 +417,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
417417 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
418418 }
419419 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
420- state, ok := entry.capturePrimary(ac, primaryCaptureRequest{
420+ state, ok := entry.captureRenderState(ac, renderCaptureRequest{
421421 bars: bars,
422422 overlays: capturedOverlays,
423423 preview: preview,
@@ -304,7 +304,7 @@ func (b *runtimeMarkBatch) flush() {
304 func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {304 func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {
305 // Local sessions retain their tab/PTY preparation below. Proxy sessions305 // Local sessions retain their tab/PTY preparation below. Proxy sessions
306 // enter the same attachment pipeline but supply their VT snapshot through306 // enter the same attachment pipeline but supply their VT snapshot through
307- // attachmentSession.capturePrimary.307+ // attachmentSession.captureRenderState.
308 sess, local := localSession(entry)308 sess, local := localSession(entry)
309 marks := d.newRuntimeMarkBatch()309 marks := d.newRuntimeMarkBatch()
310 if lease != nil {310 if lease != nil {
@@ -417,7 +417,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
417 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,417 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
418 }418 }
419 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)419 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
420- state, ok := entry.capturePrimary(ac, primaryCaptureRequest{420+ state, ok := entry.captureRenderState(ac, renderCaptureRequest{
421 bars: bars,421 bars: bars,
422 overlays: capturedOverlays,422 overlays: capturedOverlays,
423 preview: preview,423 preview: preview,
M internal/usecase/daemon/render_capture.go +12 -11
@@ -182,7 +182,7 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool {
182182 return false
183183 }
184184
185-type primaryCaptureRequest struct {
185+type renderCaptureRequest struct {
186186 bars barState
187187 overlays capturedOverlayRenderState
188188 preview picker.Preview
@@ -193,14 +193,14 @@ type primaryCaptureRequest struct {
193193 lease *attachmentLease
194194 }
195195
196-// captureLocalPrimaryRenderState is the ownership boundary for a local primary
196+// captureLocalRenderState is the ownership boundary for a local attachment
197197 // render transaction. Callers hold attachment sendMu; this function then follows
198198 // session -> tab -> pane lock order. ACK-blocked capture returns before touching
199199 // VT damage, and every captured pane records a receipt for successful emission.
200-func captureLocalPrimaryRenderState(
200+func captureLocalRenderState(
201201 sess *session,
202202 ac *attachedClient,
203- request primaryCaptureRequest,
203+ request renderCaptureRequest,
204204 ) (*capturedRenderState, bool) {
205205 bars := request.bars
206206 overlays := request.overlays
@@ -217,7 +217,7 @@ func captureLocalPrimaryRenderState(
217217 if !owned {
218218 return nil, false
219219 }
220- tb, _ := sess.paneForAttachment(ac)
220+ tb, focusedPane := sess.paneForAttachment(ac)
221221 if tb == nil {
222222 tb = sess.tabForAttachment(ac)
223223 }
@@ -244,8 +244,8 @@ func captureLocalPrimaryRenderState(
244244 defer tb.mu.Unlock()
245245 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
246246 var focus layout.PaneID
247- if tb.tree != nil {
248- focus = tb.tree.Focus
247+ if focusedPane != nil && tb.panes[focusedPane.id] == focusedPane {
248+ focus = focusedPane.id
249249 }
250250 layoutSnap := tabLayoutSnapshot{
251251 area: area, focus: focus, placements: scratch.placements,
@@ -253,6 +253,7 @@ func captureLocalPrimaryRenderState(
253253 }
254254 if scratch.layoutTab != tb || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus {
255255 layoutSnap = solveTabLayoutLocked(tb)
256+ layoutSnap.focus = focus
256257 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
257258 scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
258259 scratch.layoutTab = tb
@@ -330,10 +331,10 @@ func captureLocalPrimaryRenderState(
330331 return state, true
331332 }
332333
333-// capturePrimaryRenderState preserves the focused local test seam. Production
334-// rendering delegates through attachmentSession.capturePrimary.
335-func capturePrimaryRenderState(sess *session, ac *attachedClient, request primaryCaptureRequest) (*capturedRenderState, bool) {
336- return captureLocalPrimaryRenderState(sess, ac, request)
334+// captureRenderState preserves the focused local test seam. Production
335+// rendering delegates through attachmentSession.captureRenderState.
336+func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
337+ return captureLocalRenderState(sess, ac, request)
337338 }
338339
339340 // copyRankedRecentInto preserves the non-nil empty slice that selects
@@ -182,7 +182,7 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool {
182 return false182 return false
183 }183 }
184 184
185-type primaryCaptureRequest struct {185+type renderCaptureRequest struct {
186 bars barState186 bars barState
187 overlays capturedOverlayRenderState187 overlays capturedOverlayRenderState
188 preview picker.Preview188 preview picker.Preview
@@ -193,14 +193,14 @@ type primaryCaptureRequest struct {
193 lease *attachmentLease193 lease *attachmentLease
194 }194 }
195 195
196-// captureLocalPrimaryRenderState is the ownership boundary for a local primary196+// captureLocalRenderState is the ownership boundary for a local attachment
197 // render transaction. Callers hold attachment sendMu; this function then follows197 // render transaction. Callers hold attachment sendMu; this function then follows
198 // session -> tab -> pane lock order. ACK-blocked capture returns before touching198 // session -> tab -> pane lock order. ACK-blocked capture returns before touching
199 // VT damage, and every captured pane records a receipt for successful emission.199 // VT damage, and every captured pane records a receipt for successful emission.
200-func captureLocalPrimaryRenderState(200+func captureLocalRenderState(
201 sess *session,201 sess *session,
202 ac *attachedClient,202 ac *attachedClient,
203- request primaryCaptureRequest,203+ request renderCaptureRequest,
204 ) (*capturedRenderState, bool) {204 ) (*capturedRenderState, bool) {
205 bars := request.bars205 bars := request.bars
206 overlays := request.overlays206 overlays := request.overlays
@@ -217,7 +217,7 @@ func captureLocalPrimaryRenderState(
217 if !owned {217 if !owned {
218 return nil, false218 return nil, false
219 }219 }
220- tb, _ := sess.paneForAttachment(ac)220+ tb, focusedPane := sess.paneForAttachment(ac)
221 if tb == nil {221 if tb == nil {
222 tb = sess.tabForAttachment(ac)222 tb = sess.tabForAttachment(ac)
223 }223 }
@@ -244,8 +244,8 @@ func captureLocalPrimaryRenderState(
244 defer tb.mu.Unlock()244 defer tb.mu.Unlock()
245 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}245 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
246 var focus layout.PaneID246 var focus layout.PaneID
247- if tb.tree != nil {247+ if focusedPane != nil && tb.panes[focusedPane.id] == focusedPane {
248- focus = tb.tree.Focus248+ focus = focusedPane.id
249 }249 }
250 layoutSnap := tabLayoutSnapshot{250 layoutSnap := tabLayoutSnapshot{
251 area: area, focus: focus, placements: scratch.placements,251 area: area, focus: focus, placements: scratch.placements,
@@ -253,6 +253,7 @@ func captureLocalPrimaryRenderState(
253 }253 }
254 if scratch.layoutTab != tb || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus {254 if scratch.layoutTab != tb || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus {
255 layoutSnap = solveTabLayoutLocked(tb)255 layoutSnap = solveTabLayoutLocked(tb)
256+ layoutSnap.focus = focus
256 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)257 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
257 scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)258 scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
258 scratch.layoutTab = tb259 scratch.layoutTab = tb
@@ -330,10 +331,10 @@ func captureLocalPrimaryRenderState(
330 return state, true331 return state, true
331 }332 }
332 333
333-// capturePrimaryRenderState preserves the focused local test seam. Production334+// captureRenderState preserves the focused local test seam. Production
334-// rendering delegates through attachmentSession.capturePrimary.335+// rendering delegates through attachmentSession.captureRenderState.
335-func capturePrimaryRenderState(sess *session, ac *attachedClient, request primaryCaptureRequest) (*capturedRenderState, bool) {336+func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
336- return captureLocalPrimaryRenderState(sess, ac, request)337+ return captureLocalRenderState(sess, ac, request)
337 }338 }
338 339
339 // copyRankedRecentInto preserves the non-nil empty slice that selects340 // copyRankedRecentInto preserves the non-nil empty slice that selects
M internal/usecase/daemon/render_coordinator.go +121 -121
@@ -21,8 +21,8 @@ type renderCoordinator struct {
2121 // enqueue until fire consumes the coalesced work.
2222 queueMarked bool
2323 queueCorrelation ports.RuntimeCorrelation
24- // pendingPreview is accounted separately from the target primary wake:
25- // viewers may consume a coalesced target snapshot while that target is
24+ // pendingPreview is accounted separately from the attachment wake:
25+ // viewers may consume a coalesced target snapshot while output is
2626 // still ACK-blocked.
2727 pendingPreview bool
2828 // ackDeferred records that an expired deadline or watchdog was blocked by
@@ -40,17 +40,16 @@ type renderCoordinator struct {
4040 // batch perpetually reset its own timer.
4141 outputPressure int
4242
43- // previewWake receives the same coalesced wakes for the legacy single
44- // subscriber. previewWakes tracks picker subscriptions by viewer, so one
45- // inactive session cannot replace another viewer's live preview.
46- previewWake func(renderWake)
43+ // previewWakes tracks picker subscriptions by viewer, so one inactive session
44+ // cannot replace another viewer's live preview.
4745 previewWakes map[*attachedClient]previewSubscription
4846
49- // lease is the sole primary attachment lifecycle state. A revoked lease is
50- // retained after detach so headless preview delivery remains distinguishable
51- // from an unbound test coordinator.
52- lease *attachmentLease
53- torndown bool
47+ // attachments and leases are coordinator-owned lifecycle snapshots. Shared
48+ // render work is not tied to any one attachment; leases only fence callbacks
49+ // for the attachment that captured them.
50+ attachments map[*attachedClient]struct{}
51+ leases map[*attachedClient]*attachmentLease
52+ torndown bool
5453
5554 resize resizeRequestMetadata
5655 // Each lane owns one complete immutable worker token at a time.
@@ -111,9 +110,9 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) bool {
111110 return c.invalidateForAttachment(nil, inv)
112111 }
113112
114-// invalidateForAttachment publishes only while source is still the bound
115-// attachment. This check shares c.mu with replacement, making a stale
116-// attachment's timer callback unable to enqueue work for its replacement.
113+// invalidateForAttachment publishes only while source is still registered.
114+// This check shares c.mu with attachment lifecycle changes, making a stale
115+// timer callback unable to enqueue work for a newer attachment incarnation.
117116 // A nil source represents an internal pane/session invalidation.
118117 func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) bool {
119118 return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv)
@@ -155,9 +154,8 @@ type renderInvalidationReservation struct {
155154 func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) {
156155 c.mu.Lock()
157156 defer c.mu.Unlock()
158- detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
159157 if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) ||
160- (c.primaryDetachedLocked() && !detachedPreviewOnly) || (source != nil && c.lease != nil && c.lease.attachment != source) ||
158+ (source != nil && !c.attachmentRegisteredLocked(source)) ||
161159 (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) {
162160 return nil, false
163161 }
@@ -177,7 +175,7 @@ func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *att
177175 c.pending = true
178176 c.pendingReset = c.pendingReset || inv.reset
179177 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent
180- c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0
178+ c.pendingPreview = c.pendingPreview || len(c.previewWakes) != 0
181179 c.coalesced++
182180 reservation.arm = !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
183181 if reservation.arm {
@@ -240,8 +238,8 @@ func (r *renderInvalidationReservation) finish() {
240238 }
241239
242240 // invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it
243-// rejects a same-object replacement whose source pointer still matches but
244-// whose lifecycle lease has been revoked.
241+// rejects a callback whose source pointer still matches but whose lifecycle
242+// lease has been revoked.
245243 func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {
246244 reservation, ok := c.reserveInvalidationForLeaseAtResizeEpoch(source, lease, epoch, inv)
247245 if !ok {
@@ -257,7 +255,7 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
257255 func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) }
258256
259257 // notifyAckForLease prevents an acknowledgement captured by an obsolete active
260-// frame from flushing the replacement attachment's render queue. A nil lease is
258+// frame from flushing another attachment's render queue. A nil lease is
261259 // reserved for coordinator-owned/direct test callers.
262260 func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) {
263261 c.mu.Lock()
@@ -482,16 +480,6 @@ func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) {
482480 stopDetachedTimer(worker)
483481 }
484482
485-// subscribePreview installs fn as the preview observer for coalesced wakes.
486-func (c *renderCoordinator) subscribePreview(fn func(renderWake)) {
487- c.mu.Lock()
488- c.previewWake = fn
489- c.mu.Unlock()
490-}
491-
492-// teardownPreview removes the legacy preview subscription.
493-func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() }
494-
495483 type previewSubscription struct {
496484 generation uint64
497485 fn func(renderWake)
@@ -519,7 +507,7 @@ func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, generati
519507 }
520508
521509 // teardownPreviewFor removes viewer's observer only when it still belongs to
522-// generation. A delayed teardown cannot clear a replacement subscription.
510+// generation. A delayed teardown cannot clear a newer subscription.
523511 func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generation uint64) {
524512 c.mu.Lock()
525513 if current, ok := c.previewWakes[viewer]; ok && current.generation == generation {
@@ -528,29 +516,48 @@ func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generatio
528516 c.mu.Unlock()
529517 }
530518
531-// primaryDetachedLocked distinguishes an explicitly detached primary from an
532-// unbound coordinator used by headless tests. Preview subscribers remain live
533-// for the former, but it can never regain a primary transport without a lease.
534-func (c *renderCoordinator) primaryDetachedLocked() bool { return c.lease != nil && !c.lease.active }
519+func (c *renderCoordinator) attachmentRegisteredLocked(ac *attachedClient) bool {
520+ if ac == nil {
521+ return false
522+ }
523+ _, ok := c.attachments[ac]
524+ return ok
525+}
535526
536527 func (c *renderCoordinator) installLeaseLocked(ac *attachedClient, ready bool) *attachmentLease {
537- if c.lease != nil {
538- c.lease.active = false
528+ if ac == nil {
529+ return nil
530+ }
531+ if c.attachments == nil {
532+ c.attachments = make(map[*attachedClient]struct{})
533+ }
534+ if c.leases == nil {
535+ c.leases = make(map[*attachedClient]*attachmentLease)
536+ }
537+ c.attachments[ac] = struct{}{}
538+ if lease := c.leases[ac]; lease != nil && lease.active {
539+ lease.ready = ready
540+ return lease
539541 }
540- c.lease = &attachmentLease{attachment: ac, ready: ready, active: ac != nil}
541- return c.lease
542+ lease := &attachmentLease{attachment: ac, ready: ready, active: true}
543+ c.leases[ac] = lease
544+ return lease
542545 }
543546
544547 // attachmentLease returns the exact currently published lease for ac. Welcome
545-// continuations capture this before their transport write and cannot later
546-// bless a same-object park/resume replacement.
548+// continuations capture this before their transport write and revalidate it at
549+// every side-effect boundary.
547550 func (c *renderCoordinator) attachmentLease(ac *attachedClient) *attachmentLease {
548551 c.mu.Lock()
549552 defer c.mu.Unlock()
550- if c.torndown || c.lease == nil || !c.lease.active || c.lease.attachment != ac {
553+ if c.torndown {
554+ return nil
555+ }
556+ lease := c.leases[ac]
557+ if lease == nil || !lease.active {
551558 return nil
552559 }
553- return c.lease
560+ return lease
554561 }
555562
556563 func (c *renderCoordinator) leaseCurrent(lease *attachmentLease, ready bool) bool {
@@ -563,23 +570,30 @@ func (c *renderCoordinator) leaseCurrent(lease *attachmentLease, ready bool) boo
563570 // must not be used to retain c.mu across input routing, transport operations,
564571 // or other arbitrary handler work.
565572 func (c *renderCoordinator) leaseCurrentLocked(lease *attachmentLease, ready bool) bool {
566- return !c.torndown && lease != nil && c.lease == lease && lease.active && (!ready || lease.ready)
573+ return !c.torndown && lease != nil && lease.active &&
574+ c.leases[lease.attachment] == lease && (!ready || lease.ready)
567575 }
568576
569577 // attach binds an already-handshaken internal attachment. Route/resume use
570578 // attachWithReadiness(..., false) until their Welcome frame is accepted.
571579 func (c *renderCoordinator) attach(ac *attachedClient) { c.attachWithReadiness(ac, true) }
572580
573-func (c *renderCoordinator) attachWithReadiness(ac *attachedClient, ready bool) {
581+func (c *renderCoordinator) attachWithReadiness(ac *attachedClient, ready bool) *attachmentLease {
574582 c.mu.Lock()
575- if !c.torndown && (c.lease == nil || !c.lease.active || c.lease.attachment != ac) {
576- c.installLeaseLocked(ac, ready)
583+ defer c.mu.Unlock()
584+ return c.attachWithReadinessLocked(ac, ready)
585+}
586+
587+// attachWithReadinessLocked publishes one attachment while the coordinator
588+// lock is already held by an ordered transition publication.
589+func (c *renderCoordinator) attachWithReadinessLocked(ac *attachedClient, ready bool) *attachmentLease {
590+ if c.torndown {
591+ return nil
577592 }
578- c.mu.Unlock()
593+ return c.installLeaseLocked(ac, ready)
579594 }
580595
581596 // markAttachmentReady completes only the captured attachment incarnation.
582-// A stale Welcome must never bless a replacement that reused its client object.
583597 func (c *renderCoordinator) markAttachmentReady(lease *attachmentLease) bool {
584598 c.mu.Lock()
585599 defer c.mu.Unlock()
@@ -590,12 +604,13 @@ func (c *renderCoordinator) markAttachmentReady(lease *attachmentLease) bool {
590604 return true
591605 }
592606
593-// wakeCurrent validates a dispatched wake. paint repeats this check after
594-// sendMu is acquired, so a reused attachedClient cannot revive an old wake.
607+// wakeCurrent validates an attachment-specific wake. Shared wakes have no
608+// lease and remain valid while the coordinator is live; their paint callback
609+// rechecks each attachment's connection fence.
595610 func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
596611 c.mu.Lock()
597612 defer c.mu.Unlock()
598- return c.leaseCurrentLocked(w.lease, true)
613+ return !c.torndown && (w.lease == nil || c.leaseCurrentLocked(w.lease, true))
599614 }
600615
601616 type renderLifecycleCleanup struct {
@@ -629,12 +644,26 @@ func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleClean
629644 // beginDetachLocked is the prelocked lifecycle seam used when registry,
630645 // current-session, and lease publication must share one commit boundary.
631646 func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycleCleanup {
632- var cleanup renderLifecycleCleanup
633- if c.lease != nil && c.lease.active && c.lease.attachment == ac {
634- c.lease.active = false
635- cleanup = c.resetAttachmentLifecycleLocked()
647+ if ac == nil || !c.attachmentRegisteredLocked(ac) {
648+ return renderLifecycleCleanup{}
636649 }
637- return cleanup
650+ delete(c.attachments, ac)
651+ if lease := c.leases[ac]; lease != nil {
652+ lease.active = false
653+ delete(c.leases, ac)
654+ }
655+ // Shared pending output belongs to the session, not the detached
656+ // attachment. Keep it alive while another attachment remains registered.
657+ if len(c.attachments) != 0 {
658+ var cleanup renderLifecycleCleanup
659+ if c.resize.source == ac {
660+ _, resizeTimer := c.resizeLane.replaceLocked()
661+ _, retryTimer := c.retryLane.replaceLocked()
662+ cleanup.tokens = []*timerToken{resizeTimer, retryTimer}
663+ }
664+ return cleanup
665+ }
666+ return c.resetAttachmentLifecycleLocked()
638667 }
639668
640669 func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleCleanup {
@@ -664,45 +693,6 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
664693 c.beginDetach(ac).finish()
665694 }
666695
667-// beginReplace hands the coordinator from old to replacement without waiting
668-// for detached workers.
669-func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup {
670- c.mu.Lock()
671- cleanup, _ := c.beginReplaceLocked(old, replacement, ready)
672- c.mu.Unlock()
673- return cleanup
674-}
675-
676-// beginReplaceLocked requires c.mu. Callers validate canReplaceLocked before
677-// their ownership commit, making lease installation non-failing afterward.
678-func (c *renderCoordinator) beginReplaceLocked(old, replacement *attachedClient, ready bool) (renderLifecycleCleanup, *attachmentLease) {
679- if !c.canReplaceLocked(old, replacement) {
680- return renderLifecycleCleanup{}, nil
681- }
682- lease := c.installLeaseLocked(replacement, ready)
683- return c.resetAttachmentLifecycleLocked(), lease
684-}
685-
686-func (c *renderCoordinator) canReplaceLocked(old, replacement *attachedClient) bool {
687- // A coordinator may be installed while replacing a legacy attachment which
688- // predated coordinator ownership. Any inactive lease is detached historical
689- // state; a resume may also replace the same client object because registry and
690- // transport validation proved a new role incarnation.
691- return !c.torndown && (c.lease == nil || !c.lease.active || c.lease.attachment == old || c.lease.attachment == replacement)
692-}
693-
694-// noteReplace is for callers that hold no outer lifecycle locks.
695-func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) {
696- ready := true
697- if len(readiness) != 0 {
698- ready = readiness[0]
699- }
700- c.beginReplace(old, replacement, ready).finish()
701-}
702-
703-// notePark invalidates pending wakes when the attachment parks for resume.
704-func (c *renderCoordinator) notePark(ac *attachedClient) { c.noteDetach(ac) }
705-
706696 // beginSessionTeardown is terminal phase one. It rejects future worker
707697 // registration and atomically detaches every complete lane token.
708698 func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
@@ -712,10 +702,12 @@ func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
712702 return renderLifecycleCleanup{}
713703 }
714704 c.torndown = true
715- if c.lease != nil {
716- c.lease.active = false
705+ for ac, lease := range c.leases {
706+ lease.active = false
707+ delete(c.attachments, ac)
717708 }
718- c.previewWake = nil
709+ c.leases = nil
710+ c.attachments = nil
719711 c.previewWakes = nil
720712 c.pending = false
721713 _, resizeTimer := c.resizeLane.replaceLocked()
@@ -751,7 +743,7 @@ func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, r
751743 if rc.invalidateForAttachment(ac, invalidation) {
752744 return
753745 }
754- // Secondary attachments do not borrow the coordinator's primary lease.
746+ // Additional attachments do not borrow another attachment's lease.
755747 // Their shared mutation still schedules the session wake, whose fanout
756748 // paints every live attachment with its own output state.
757749 if ac != nil && attachmentRegistered(entry, ac) && ac.currentAttachmentSession() == entry {
@@ -814,10 +806,18 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa
814806 // Do not probe ACK capacity through that lock before this incarnation is
815807 // ready: a deadline that expires during the handshake must complete and
816808 // leave work pending, rather than wedging behind Welcome.
817- handshakePending := c.lease != nil && c.lease.active && !c.lease.ready
809+ hasLiveAttachment, allAttachmentsReady := false, true
810+ for _, lease := range c.leases {
811+ if lease.active {
812+ hasLiveAttachment = true
813+ allAttachmentsReady = allAttachmentsReady && lease.ready
814+ }
815+ }
816+ handshakePending := hasLiveAttachment && !allAttachmentsReady
817+ previewOnly := !hasLiveAttachment && len(c.previewWakes) != 0
818818 ackReady := c.opts.ackReady
819819 c.mu.Unlock()
820- ready := !handshakePending && (ackReady == nil || ackReady())
820+ ready := !handshakePending && (previewOnly || ackReady == nil || ackReady())
821821
822822 // The readiness callback can detach, replace, or publish a sync batch.
823823 // syncGateOpenLocked retries predicate evaluation on every registry change
@@ -835,17 +835,20 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa
835835 c.mu.Unlock()
836836 return
837837 }
838- // A detached target has no primary transport or ACK window. Its pending
839- // state belongs exclusively to picker previews and must be consumed after
840- // notification rather than accumulating ackDeferred forever. Do not pass
841- // its revoked primary lease to preview consumers.
842- headlessPreviewOnly := c.primaryDetachedLocked()
843- lease := c.lease
844- if headlessPreviewOnly {
845- lease = nil
846- }
847- w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, lease: lease}
848- if !headlessPreviewOnly && (!ready || (c.lease != nil && c.lease.active && !c.lease.ready)) {
838+ // Shared invalidations are consumed once and dispatched as a shared wake.
839+ // Capture each live lease so a park/resume of the same attachment object
840+ // cannot let this already-dispatched wake paint the new connection.
841+ var attachmentLeases map[*attachedClient]*attachmentLease
842+ if len(c.leases) != 0 {
843+ attachmentLeases = make(map[*attachedClient]*attachmentLease, len(c.leases))
844+ for ac, lease := range c.leases {
845+ if lease != nil && lease.active {
846+ attachmentLeases[ac] = lease
847+ }
848+ }
849+ }
850+ w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, attachmentLeases: attachmentLeases, watchdog: watchdog}
851+ if !ready || handshakePending {
849852 var ackStart *ackBlockedSpan
850853 if !ready && deadline {
851854 c.ackDeferred = true
@@ -892,7 +895,7 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa
892895 if ackBlocked != nil {
893896 ackBlocked.finish(true)
894897 }
895- if wake != nil && !headlessPreviewOnly {
898+ if wake != nil {
896899 wake(w)
897900 }
898901 c.notifyPreviews(w, preview, previews)
@@ -937,8 +940,8 @@ func (c *renderCoordinator) syncGateOpenLocked() bool {
937940 }
938941 }
939942
940-// takePendingPreviewsLocked snapshots and consumes the preview delivery for
941-// this target generation. Unlike the target primary, this is never ACK-gated.
943+// takePendingPreviewsLocked snapshots and consumes picker preview delivery
944+// for this target generation. Preview callbacks are never ACK-gated.
942945 func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) {
943946 if !c.pendingPreview {
944947 return nil, nil
@@ -948,13 +951,10 @@ func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []fun
948951 for _, subscription := range c.previewWakes {
949952 previews = append(previews, subscription.fn)
950953 }
951- return c.previewWake, previews
954+ return nil, previews
952955 }
953956
954-func (c *renderCoordinator) notifyPreviews(w renderWake, preview func(renderWake), previews []func(renderWake)) {
955- if preview != nil {
956- preview(w)
957- }
957+func (c *renderCoordinator) notifyPreviews(w renderWake, _ func(renderWake), previews []func(renderWake)) {
958958 for _, fn := range previews {
959959 fn(w)
960960 }
@@ -21,8 +21,8 @@ type renderCoordinator struct {
21 // enqueue until fire consumes the coalesced work.21 // enqueue until fire consumes the coalesced work.
22 queueMarked bool22 queueMarked bool
23 queueCorrelation ports.RuntimeCorrelation23 queueCorrelation ports.RuntimeCorrelation
24- // pendingPreview is accounted separately from the target primary wake:24+ // pendingPreview is accounted separately from the attachment wake:
25- // viewers may consume a coalesced target snapshot while that target is25+ // viewers may consume a coalesced target snapshot while output is
26 // still ACK-blocked.26 // still ACK-blocked.
27 pendingPreview bool27 pendingPreview bool
28 // ackDeferred records that an expired deadline or watchdog was blocked by28 // ackDeferred records that an expired deadline or watchdog was blocked by
@@ -40,17 +40,16 @@ type renderCoordinator struct {
40 // batch perpetually reset its own timer.40 // batch perpetually reset its own timer.
41 outputPressure int41 outputPressure int
42 42
43- // previewWake receives the same coalesced wakes for the legacy single43+ // previewWakes tracks picker subscriptions by viewer, so one inactive session
44- // subscriber. previewWakes tracks picker subscriptions by viewer, so one44+ // cannot replace another viewer's live preview.
45- // inactive session cannot replace another viewer's live preview.
46- previewWake func(renderWake)
47 previewWakes map[*attachedClient]previewSubscription45 previewWakes map[*attachedClient]previewSubscription
48 46
49- // lease is the sole primary attachment lifecycle state. A revoked lease is47+ // attachments and leases are coordinator-owned lifecycle snapshots. Shared
50- // retained after detach so headless preview delivery remains distinguishable48+ // render work is not tied to any one attachment; leases only fence callbacks
51- // from an unbound test coordinator.49+ // for the attachment that captured them.
52- lease *attachmentLease50+ attachments map[*attachedClient]struct{}
53- torndown bool51+ leases map[*attachedClient]*attachmentLease
52+ torndown bool
54 53
55 resize resizeRequestMetadata54 resize resizeRequestMetadata
56 // Each lane owns one complete immutable worker token at a time.55 // Each lane owns one complete immutable worker token at a time.
@@ -111,9 +110,9 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) bool {
111 return c.invalidateForAttachment(nil, inv)110 return c.invalidateForAttachment(nil, inv)
112 }111 }
113 112
114-// invalidateForAttachment publishes only while source is still the bound113+// invalidateForAttachment publishes only while source is still registered.
115-// attachment. This check shares c.mu with replacement, making a stale114+// This check shares c.mu with attachment lifecycle changes, making a stale
116-// attachment's timer callback unable to enqueue work for its replacement.115+// timer callback unable to enqueue work for a newer attachment incarnation.
117 // A nil source represents an internal pane/session invalidation.116 // A nil source represents an internal pane/session invalidation.
118 func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) bool {117 func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) bool {
119 return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv)118 return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv)
@@ -155,9 +154,8 @@ type renderInvalidationReservation struct {
155 func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) {154 func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) {
156 c.mu.Lock()155 c.mu.Lock()
157 defer c.mu.Unlock()156 defer c.mu.Unlock()
158- detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
159 if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) ||157 if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) ||
160- (c.primaryDetachedLocked() && !detachedPreviewOnly) || (source != nil && c.lease != nil && c.lease.attachment != source) ||158+ (source != nil && !c.attachmentRegisteredLocked(source)) ||
161 (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) {159 (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) {
162 return nil, false160 return nil, false
163 }161 }
@@ -177,7 +175,7 @@ func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *att
177 c.pending = true175 c.pending = true
178 c.pendingReset = c.pendingReset || inv.reset176 c.pendingReset = c.pendingReset || inv.reset
179 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent177 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent
180- c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0178+ c.pendingPreview = c.pendingPreview || len(c.previewWakes) != 0
181 c.coalesced++179 c.coalesced++
182 reservation.arm = !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue180 reservation.arm = !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
183 if reservation.arm {181 if reservation.arm {
@@ -240,8 +238,8 @@ func (r *renderInvalidationReservation) finish() {
240 }238 }
241 239
242 // invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it240 // invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it
243-// rejects a same-object replacement whose source pointer still matches but241+// rejects a callback whose source pointer still matches but whose lifecycle
244-// whose lifecycle lease has been revoked.242+// lease has been revoked.
245 func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {243 func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {
246 reservation, ok := c.reserveInvalidationForLeaseAtResizeEpoch(source, lease, epoch, inv)244 reservation, ok := c.reserveInvalidationForLeaseAtResizeEpoch(source, lease, epoch, inv)
247 if !ok {245 if !ok {
@@ -257,7 +255,7 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
257 func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) }255 func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) }
258 256
259 // notifyAckForLease prevents an acknowledgement captured by an obsolete active257 // notifyAckForLease prevents an acknowledgement captured by an obsolete active
260-// frame from flushing the replacement attachment's render queue. A nil lease is258+// frame from flushing another attachment's render queue. A nil lease is
261 // reserved for coordinator-owned/direct test callers.259 // reserved for coordinator-owned/direct test callers.
262 func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) {260 func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) {
263 c.mu.Lock()261 c.mu.Lock()
@@ -482,16 +480,6 @@ func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) {
482 stopDetachedTimer(worker)480 stopDetachedTimer(worker)
483 }481 }
484 482
485-// subscribePreview installs fn as the preview observer for coalesced wakes.
486-func (c *renderCoordinator) subscribePreview(fn func(renderWake)) {
487- c.mu.Lock()
488- c.previewWake = fn
489- c.mu.Unlock()
490-}
491-
492-// teardownPreview removes the legacy preview subscription.
493-func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() }
494-
495 type previewSubscription struct {483 type previewSubscription struct {
496 generation uint64484 generation uint64
497 fn func(renderWake)485 fn func(renderWake)
@@ -519,7 +507,7 @@ func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, generati
519 }507 }
520 508
521 // teardownPreviewFor removes viewer's observer only when it still belongs to509 // teardownPreviewFor removes viewer's observer only when it still belongs to
522-// generation. A delayed teardown cannot clear a replacement subscription.510+// generation. A delayed teardown cannot clear a newer subscription.
523 func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generation uint64) {511 func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generation uint64) {
524 c.mu.Lock()512 c.mu.Lock()
525 if current, ok := c.previewWakes[viewer]; ok && current.generation == generation {513 if current, ok := c.previewWakes[viewer]; ok && current.generation == generation {
@@ -528,29 +516,48 @@ func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generatio
528 c.mu.Unlock()516 c.mu.Unlock()
529 }517 }
530 518
531-// primaryDetachedLocked distinguishes an explicitly detached primary from an519+func (c *renderCoordinator) attachmentRegisteredLocked(ac *attachedClient) bool {
532-// unbound coordinator used by headless tests. Preview subscribers remain live520+ if ac == nil {
533-// for the former, but it can never regain a primary transport without a lease.521+ return false
534-func (c *renderCoordinator) primaryDetachedLocked() bool { return c.lease != nil && !c.lease.active }522+ }
523+ _, ok := c.attachments[ac]
524+ return ok
525+}
535 526
536 func (c *renderCoordinator) installLeaseLocked(ac *attachedClient, ready bool) *attachmentLease {527 func (c *renderCoordinator) installLeaseLocked(ac *attachedClient, ready bool) *attachmentLease {
537- if c.lease != nil {528+ if ac == nil {
538- c.lease.active = false529+ return nil
530+ }
531+ if c.attachments == nil {
532+ c.attachments = make(map[*attachedClient]struct{})
533+ }
534+ if c.leases == nil {
535+ c.leases = make(map[*attachedClient]*attachmentLease)
536+ }
537+ c.attachments[ac] = struct{}{}
538+ if lease := c.leases[ac]; lease != nil && lease.active {
539+ lease.ready = ready
540+ return lease
539 }541 }
540- c.lease = &attachmentLease{attachment: ac, ready: ready, active: ac != nil}542+ lease := &attachmentLease{attachment: ac, ready: ready, active: true}
541- return c.lease543+ c.leases[ac] = lease
544+ return lease
542 }545 }
543 546
544 // attachmentLease returns the exact currently published lease for ac. Welcome547 // attachmentLease returns the exact currently published lease for ac. Welcome
545-// continuations capture this before their transport write and cannot later548+// continuations capture this before their transport write and revalidate it at
546-// bless a same-object park/resume replacement.549+// every side-effect boundary.
547 func (c *renderCoordinator) attachmentLease(ac *attachedClient) *attachmentLease {550 func (c *renderCoordinator) attachmentLease(ac *attachedClient) *attachmentLease {
548 c.mu.Lock()551 c.mu.Lock()
549 defer c.mu.Unlock()552 defer c.mu.Unlock()
550- if c.torndown || c.lease == nil || !c.lease.active || c.lease.attachment != ac {553+ if c.torndown {
554+ return nil
555+ }
556+ lease := c.leases[ac]
557+ if lease == nil || !lease.active {
551 return nil558 return nil
552 }559 }
553- return c.lease560+ return lease
554 }561 }
555 562
556 func (c *renderCoordinator) leaseCurrent(lease *attachmentLease, ready bool) bool {563 func (c *renderCoordinator) leaseCurrent(lease *attachmentLease, ready bool) bool {
@@ -563,23 +570,30 @@ func (c *renderCoordinator) leaseCurrent(lease *attachmentLease, ready bool) boo
563 // must not be used to retain c.mu across input routing, transport operations,570 // must not be used to retain c.mu across input routing, transport operations,
564 // or other arbitrary handler work.571 // or other arbitrary handler work.
565 func (c *renderCoordinator) leaseCurrentLocked(lease *attachmentLease, ready bool) bool {572 func (c *renderCoordinator) leaseCurrentLocked(lease *attachmentLease, ready bool) bool {
566- return !c.torndown && lease != nil && c.lease == lease && lease.active && (!ready || lease.ready)573+ return !c.torndown && lease != nil && lease.active &&
574+ c.leases[lease.attachment] == lease && (!ready || lease.ready)
567 }575 }
568 576
569 // attach binds an already-handshaken internal attachment. Route/resume use577 // attach binds an already-handshaken internal attachment. Route/resume use
570 // attachWithReadiness(..., false) until their Welcome frame is accepted.578 // attachWithReadiness(..., false) until their Welcome frame is accepted.
571 func (c *renderCoordinator) attach(ac *attachedClient) { c.attachWithReadiness(ac, true) }579 func (c *renderCoordinator) attach(ac *attachedClient) { c.attachWithReadiness(ac, true) }
572 580
573-func (c *renderCoordinator) attachWithReadiness(ac *attachedClient, ready bool) {581+func (c *renderCoordinator) attachWithReadiness(ac *attachedClient, ready bool) *attachmentLease {
574 c.mu.Lock()582 c.mu.Lock()
575- if !c.torndown && (c.lease == nil || !c.lease.active || c.lease.attachment != ac) {583+ defer c.mu.Unlock()
576- c.installLeaseLocked(ac, ready)584+ return c.attachWithReadinessLocked(ac, ready)
585+}
586+
587+// attachWithReadinessLocked publishes one attachment while the coordinator
588+// lock is already held by an ordered transition publication.
589+func (c *renderCoordinator) attachWithReadinessLocked(ac *attachedClient, ready bool) *attachmentLease {
590+ if c.torndown {
591+ return nil
577 }592 }
578- c.mu.Unlock()593+ return c.installLeaseLocked(ac, ready)
579 }594 }
580 595
581 // markAttachmentReady completes only the captured attachment incarnation.596 // markAttachmentReady completes only the captured attachment incarnation.
582-// A stale Welcome must never bless a replacement that reused its client object.
583 func (c *renderCoordinator) markAttachmentReady(lease *attachmentLease) bool {597 func (c *renderCoordinator) markAttachmentReady(lease *attachmentLease) bool {
584 c.mu.Lock()598 c.mu.Lock()
585 defer c.mu.Unlock()599 defer c.mu.Unlock()
@@ -590,12 +604,13 @@ func (c *renderCoordinator) markAttachmentReady(lease *attachmentLease) bool {
590 return true604 return true
591 }605 }
592 606
593-// wakeCurrent validates a dispatched wake. paint repeats this check after607+// wakeCurrent validates an attachment-specific wake. Shared wakes have no
594-// sendMu is acquired, so a reused attachedClient cannot revive an old wake.608+// lease and remain valid while the coordinator is live; their paint callback
609+// rechecks each attachment's connection fence.
595 func (c *renderCoordinator) wakeCurrent(w renderWake) bool {610 func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
596 c.mu.Lock()611 c.mu.Lock()
597 defer c.mu.Unlock()612 defer c.mu.Unlock()
598- return c.leaseCurrentLocked(w.lease, true)613+ return !c.torndown && (w.lease == nil || c.leaseCurrentLocked(w.lease, true))
599 }614 }
600 615
601 type renderLifecycleCleanup struct {616 type renderLifecycleCleanup struct {
@@ -629,12 +644,26 @@ func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleClean
629 // beginDetachLocked is the prelocked lifecycle seam used when registry,644 // beginDetachLocked is the prelocked lifecycle seam used when registry,
630 // current-session, and lease publication must share one commit boundary.645 // current-session, and lease publication must share one commit boundary.
631 func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycleCleanup {646 func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycleCleanup {
632- var cleanup renderLifecycleCleanup647+ if ac == nil || !c.attachmentRegisteredLocked(ac) {
633- if c.lease != nil && c.lease.active && c.lease.attachment == ac {648+ return renderLifecycleCleanup{}
634- c.lease.active = false
635- cleanup = c.resetAttachmentLifecycleLocked()
636 }649 }
637- return cleanup650+ delete(c.attachments, ac)
651+ if lease := c.leases[ac]; lease != nil {
652+ lease.active = false
653+ delete(c.leases, ac)
654+ }
655+ // Shared pending output belongs to the session, not the detached
656+ // attachment. Keep it alive while another attachment remains registered.
657+ if len(c.attachments) != 0 {
658+ var cleanup renderLifecycleCleanup
659+ if c.resize.source == ac {
660+ _, resizeTimer := c.resizeLane.replaceLocked()
661+ _, retryTimer := c.retryLane.replaceLocked()
662+ cleanup.tokens = []*timerToken{resizeTimer, retryTimer}
663+ }
664+ return cleanup
665+ }
666+ return c.resetAttachmentLifecycleLocked()
638 }667 }
639 668
640 func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleCleanup {669 func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleCleanup {
@@ -664,45 +693,6 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
664 c.beginDetach(ac).finish()693 c.beginDetach(ac).finish()
665 }694 }
666 695
667-// beginReplace hands the coordinator from old to replacement without waiting
668-// for detached workers.
669-func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup {
670- c.mu.Lock()
671- cleanup, _ := c.beginReplaceLocked(old, replacement, ready)
672- c.mu.Unlock()
673- return cleanup
674-}
675-
676-// beginReplaceLocked requires c.mu. Callers validate canReplaceLocked before
677-// their ownership commit, making lease installation non-failing afterward.
678-func (c *renderCoordinator) beginReplaceLocked(old, replacement *attachedClient, ready bool) (renderLifecycleCleanup, *attachmentLease) {
679- if !c.canReplaceLocked(old, replacement) {
680- return renderLifecycleCleanup{}, nil
681- }
682- lease := c.installLeaseLocked(replacement, ready)
683- return c.resetAttachmentLifecycleLocked(), lease
684-}
685-
686-func (c *renderCoordinator) canReplaceLocked(old, replacement *attachedClient) bool {
687- // A coordinator may be installed while replacing a legacy attachment which
688- // predated coordinator ownership. Any inactive lease is detached historical
689- // state; a resume may also replace the same client object because registry and
690- // transport validation proved a new role incarnation.
691- return !c.torndown && (c.lease == nil || !c.lease.active || c.lease.attachment == old || c.lease.attachment == replacement)
692-}
693-
694-// noteReplace is for callers that hold no outer lifecycle locks.
695-func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) {
696- ready := true
697- if len(readiness) != 0 {
698- ready = readiness[0]
699- }
700- c.beginReplace(old, replacement, ready).finish()
701-}
702-
703-// notePark invalidates pending wakes when the attachment parks for resume.
704-func (c *renderCoordinator) notePark(ac *attachedClient) { c.noteDetach(ac) }
705-
706 // beginSessionTeardown is terminal phase one. It rejects future worker696 // beginSessionTeardown is terminal phase one. It rejects future worker
707 // registration and atomically detaches every complete lane token.697 // registration and atomically detaches every complete lane token.
708 func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {698 func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
@@ -712,10 +702,12 @@ func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
712 return renderLifecycleCleanup{}702 return renderLifecycleCleanup{}
713 }703 }
714 c.torndown = true704 c.torndown = true
715- if c.lease != nil {705+ for ac, lease := range c.leases {
716- c.lease.active = false706+ lease.active = false
707+ delete(c.attachments, ac)
717 }708 }
718- c.previewWake = nil709+ c.leases = nil
710+ c.attachments = nil
719 c.previewWakes = nil711 c.previewWakes = nil
720 c.pending = false712 c.pending = false
721 _, resizeTimer := c.resizeLane.replaceLocked()713 _, resizeTimer := c.resizeLane.replaceLocked()
@@ -751,7 +743,7 @@ func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, r
751 if rc.invalidateForAttachment(ac, invalidation) {743 if rc.invalidateForAttachment(ac, invalidation) {
752 return744 return
753 }745 }
754- // Secondary attachments do not borrow the coordinator's primary lease.746+ // Additional attachments do not borrow another attachment's lease.
755 // Their shared mutation still schedules the session wake, whose fanout747 // Their shared mutation still schedules the session wake, whose fanout
756 // paints every live attachment with its own output state.748 // paints every live attachment with its own output state.
757 if ac != nil && attachmentRegistered(entry, ac) && ac.currentAttachmentSession() == entry {749 if ac != nil && attachmentRegistered(entry, ac) && ac.currentAttachmentSession() == entry {
@@ -814,10 +806,18 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa
814 // Do not probe ACK capacity through that lock before this incarnation is806 // Do not probe ACK capacity through that lock before this incarnation is
815 // ready: a deadline that expires during the handshake must complete and807 // ready: a deadline that expires during the handshake must complete and
816 // leave work pending, rather than wedging behind Welcome.808 // leave work pending, rather than wedging behind Welcome.
817- handshakePending := c.lease != nil && c.lease.active && !c.lease.ready809+ hasLiveAttachment, allAttachmentsReady := false, true
810+ for _, lease := range c.leases {
811+ if lease.active {
812+ hasLiveAttachment = true
813+ allAttachmentsReady = allAttachmentsReady && lease.ready
814+ }
815+ }
816+ handshakePending := hasLiveAttachment && !allAttachmentsReady
817+ previewOnly := !hasLiveAttachment && len(c.previewWakes) != 0
818 ackReady := c.opts.ackReady818 ackReady := c.opts.ackReady
819 c.mu.Unlock()819 c.mu.Unlock()
820- ready := !handshakePending && (ackReady == nil || ackReady())820+ ready := !handshakePending && (previewOnly || ackReady == nil || ackReady())
821 821
822 // The readiness callback can detach, replace, or publish a sync batch.822 // The readiness callback can detach, replace, or publish a sync batch.
823 // syncGateOpenLocked retries predicate evaluation on every registry change823 // syncGateOpenLocked retries predicate evaluation on every registry change
@@ -835,17 +835,20 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa
835 c.mu.Unlock()835 c.mu.Unlock()
836 return836 return
837 }837 }
838- // A detached target has no primary transport or ACK window. Its pending838+ // Shared invalidations are consumed once and dispatched as a shared wake.
839- // state belongs exclusively to picker previews and must be consumed after839+ // Capture each live lease so a park/resume of the same attachment object
840- // notification rather than accumulating ackDeferred forever. Do not pass840+ // cannot let this already-dispatched wake paint the new connection.
841- // its revoked primary lease to preview consumers.841+ var attachmentLeases map[*attachedClient]*attachmentLease
842- headlessPreviewOnly := c.primaryDetachedLocked()842+ if len(c.leases) != 0 {
843- lease := c.lease843+ attachmentLeases = make(map[*attachedClient]*attachmentLease, len(c.leases))
844- if headlessPreviewOnly {844+ for ac, lease := range c.leases {
845- lease = nil845+ if lease != nil && lease.active {
846- }846+ attachmentLeases[ac] = lease
847- w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, lease: lease}847+ }
848- if !headlessPreviewOnly && (!ready || (c.lease != nil && c.lease.active && !c.lease.ready)) {848+ }
849+ }
850+ w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, attachmentLeases: attachmentLeases, watchdog: watchdog}
851+ if !ready || handshakePending {
849 var ackStart *ackBlockedSpan852 var ackStart *ackBlockedSpan
850 if !ready && deadline {853 if !ready && deadline {
851 c.ackDeferred = true854 c.ackDeferred = true
@@ -892,7 +895,7 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa
892 if ackBlocked != nil {895 if ackBlocked != nil {
893 ackBlocked.finish(true)896 ackBlocked.finish(true)
894 }897 }
895- if wake != nil && !headlessPreviewOnly {898+ if wake != nil {
896 wake(w)899 wake(w)
897 }900 }
898 c.notifyPreviews(w, preview, previews)901 c.notifyPreviews(w, preview, previews)
@@ -937,8 +940,8 @@ func (c *renderCoordinator) syncGateOpenLocked() bool {
937 }940 }
938 }941 }
939 942
940-// takePendingPreviewsLocked snapshots and consumes the preview delivery for943+// takePendingPreviewsLocked snapshots and consumes picker preview delivery
941-// this target generation. Unlike the target primary, this is never ACK-gated.944+// for this target generation. Preview callbacks are never ACK-gated.
942 func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) {945 func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) {
943 if !c.pendingPreview {946 if !c.pendingPreview {
944 return nil, nil947 return nil, nil
@@ -948,13 +951,10 @@ func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []fun
948 for _, subscription := range c.previewWakes {951 for _, subscription := range c.previewWakes {
949 previews = append(previews, subscription.fn)952 previews = append(previews, subscription.fn)
950 }953 }
951- return c.previewWake, previews954+ return nil, previews
952 }955 }
953 956
954-func (c *renderCoordinator) notifyPreviews(w renderWake, preview func(renderWake), previews []func(renderWake)) {957+func (c *renderCoordinator) notifyPreviews(w renderWake, _ func(renderWake), previews []func(renderWake)) {
955- if preview != nil {
956- preview(w)
957- }
958 for _, fn := range previews {958 for _, fn := range previews {
959 fn(w)959 fn(w)
960 }960 }
M internal/usecase/daemon/render_coordinator_handshake_test.go +2 -2
@@ -32,7 +32,7 @@ func TestRenderCoordinatorFreshWakeWaitsForWelcome(t *testing.T) {
3232 wake := awaitWake(t, h.wakes)
3333 require.True(t, wake.reset)
3434 require.Equal(t, 1, wake.coalesced)
35- require.Same(t, ac, wake.lease.attachment)
35+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
3636 requireNoWake(t, h.wakes)
3737 }
3838
@@ -45,7 +45,7 @@ func TestRenderCoordinatorParkedResumeRequiresNewWelcome(t *testing.T) {
4545 h.rc.attachWithReadiness(ac, false)
4646 firstLease := h.rc.attachmentLease(ac)
4747 require.True(t, h.rc.markAttachmentReady(firstLease))
48- h.rc.notePark(ac)
48+ h.rc.noteDetach(ac)
4949 h.rc.attachWithReadiness(ac, false)
5050 resumedLease := h.rc.attachmentLease(ac)
5151 require.NotNil(t, resumedLease)
@@ -32,7 +32,7 @@ func TestRenderCoordinatorFreshWakeWaitsForWelcome(t *testing.T) {
32 wake := awaitWake(t, h.wakes)32 wake := awaitWake(t, h.wakes)
33 require.True(t, wake.reset)33 require.True(t, wake.reset)
34 require.Equal(t, 1, wake.coalesced)34 require.Equal(t, 1, wake.coalesced)
35- require.Same(t, ac, wake.lease.attachment)35+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
36 requireNoWake(t, h.wakes)36 requireNoWake(t, h.wakes)
37 }37 }
38 38
@@ -45,7 +45,7 @@ func TestRenderCoordinatorParkedResumeRequiresNewWelcome(t *testing.T) {
45 h.rc.attachWithReadiness(ac, false)45 h.rc.attachWithReadiness(ac, false)
46 firstLease := h.rc.attachmentLease(ac)46 firstLease := h.rc.attachmentLease(ac)
47 require.True(t, h.rc.markAttachmentReady(firstLease))47 require.True(t, h.rc.markAttachmentReady(firstLease))
48- h.rc.notePark(ac)48+ h.rc.noteDetach(ac)
49 h.rc.attachWithReadiness(ac, false)49 h.rc.attachWithReadiness(ac, false)
50 resumedLease := h.rc.attachmentLease(ac)50 resumedLease := h.rc.attachmentLease(ac)
51 require.NotNil(t, resumedLease)51 require.NotNil(t, resumedLease)
M internal/usecase/daemon/render_coordinator_publication_test.go +2 -1
@@ -14,7 +14,8 @@ func TestCoordinatorRejectsStaleAttachmentInvalidationAfterReplacement(t *testin
1414 old := &attachedClient{}
1515 replacement := &attachedClient{}
1616 rc.attach(old)
17- rc.noteReplace(old, replacement)
17+ rc.noteDetach(old)
18+ rc.attach(replacement)
1819
1920 // This models a stale resize callback that passed its sendMu generation
2021 // check immediately before a replacement was published.
@@ -14,7 +14,8 @@ func TestCoordinatorRejectsStaleAttachmentInvalidationAfterReplacement(t *testin
14 old := &attachedClient{}14 old := &attachedClient{}
15 replacement := &attachedClient{}15 replacement := &attachedClient{}
16 rc.attach(old)16 rc.attach(old)
17- rc.noteReplace(old, replacement)17+ rc.noteDetach(old)
18+ rc.attach(replacement)
18 19
19 // This models a stale resize callback that passed its sendMu generation20 // This models a stale resize callback that passed its sendMu generation
20 // check immediately before a replacement was published.21 // check immediately before a replacement was published.
M internal/usecase/daemon/render_coordinator_test.go +37 -66
@@ -181,7 +181,9 @@ func (h *coordinatorHarness) armedTimers(t *testing.T) []*coordinatorMockTimer {
181181 // deterministic contract failure, not a slow behavior to poll for.
182182 func awaitWake(t *testing.T, ch chan renderWake) renderWake {
183183 t.Helper()
184- return awaitTestValue(t, ch, "coordinator did not publish a wake after fake-clock advancement")
184+ wake := awaitTestValue(t, ch, "coordinator did not publish a wake after fake-clock advancement")
185+ wake.attachmentLeases = nil
186+ return wake
185187 }
186188
187189 func requireNoWake(t *testing.T, ch chan renderWake) {
@@ -407,8 +409,7 @@ func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T
407409 epoch := awaitTestValue(t, resizeDone, "ack readiness re-entry blocked resize metadata")
408410 require.Equal(t, uint64(1), epoch)
409411 wake := awaitWake(t, wakes)
410- require.Same(t, owner, wake.lease.attachment)
411- wake.lease = nil
412+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
412413 require.Equal(t, renderWake{coalesced: 1}, wake)
413414 awaitTestCompletion(t, fireDone, "fire did not return after wake")
414415 }
@@ -749,7 +750,8 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
749750
750751 func TestRenderCoordinatorPreviewSubscription(t *testing.T) {
751752 h := newCoordinatorHarness(t)
752- h.rc.subscribePreview(func(w renderWake) { h.previews <- w })
753+ viewer := &attachedClient{}
754+ h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
753755
754756 h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
755757 timers := h.armedTimers(t)
@@ -760,7 +762,7 @@ func TestRenderCoordinatorPreviewSubscription(t *testing.T) {
760762 require.Equal(t, owner, preview, "a subscribed preview observes the same coalesced wake")
761763 requireNoWake(t, h.previews)
762764
763- h.rc.teardownPreview()
765+ h.rc.teardownPreviewFor(viewer, 1)
764766 h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
765767 timers = h.armedTimers(t)
766768 require.NotEmpty(t, timers, "invalidate must arm a deadline timer")
@@ -795,24 +797,24 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
795797 timers[0].ch <- time.Time{}
796798
797799 preview := awaitWake(t, h.previews)
798- if tc.attach {
799- require.NotNil(t, preview.lease.attachment)
800- }
801- preview.lease = nil
802800 require.Equal(t, renderWake{coalesced: 1}, preview,
803801 "the viewer preview must receive target output without target ACK capacity")
804- requireNoWake(t, h.wakes)
805802 requireNoWake(t, h.previews)
803+ if tc.attach {
804+ requireNoWake(t, h.wakes)
805+ } else {
806+ wake := awaitWake(t, h.wakes)
807+ require.Equal(t, renderWake{coalesced: 1}, wake)
808+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
809+ }
806810
807811 h.ackReady.Store(true)
808812 h.rc.notifyAck()
809- wake := awaitWake(t, h.wakes)
810813 if tc.attach {
811- require.NotNil(t, wake.lease.attachment)
814+ wake := awaitWake(t, h.wakes)
815+ require.Equal(t, renderWake{coalesced: 1}, wake,
816+ "the target frame remains pending for its ACK")
812817 }
813- wake.lease = nil
814- require.Equal(t, renderWake{coalesced: 1}, wake,
815- "the target primary frame remains pending for its own ACK")
816818 requireNoWake(t, h.previews)
817819 })
818820 }
@@ -837,9 +839,10 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
837839 require.Len(t, timers, 1)
838840 timers[0].ch <- time.Time{}
839841 preview := awaitWake(t, h.previews)
840- require.Nil(t, preview.lease, "headless preview wakes must not retain a revoked primary lease")
841842 require.Equal(t, renderWake{coalesced: 1}, preview)
842- requireNoWake(t, h.wakes)
843+ wake := awaitWake(t, h.wakes)
844+ require.Equal(t, renderWake{coalesced: 1}, wake)
845+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
843846
844847 h.rc.mu.Lock()
845848 require.False(t, h.rc.pending)
@@ -847,9 +850,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
847850 require.False(t, h.rc.ackDeferred)
848851 require.False(t, h.rc.deadlineDue)
849852 require.Zero(t, h.rc.coalesced)
850- require.True(t, h.rc.primaryDetachedLocked())
851- require.NotNil(t, h.rc.lease)
852- require.False(t, h.rc.lease.active)
853+ require.Empty(t, h.rc.attachments)
853854 h.rc.mu.Unlock()
854855 }
855856
@@ -863,7 +864,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
863864 timers[0].ch <- time.Time{}
864865 wake := awaitWake(t, h.wakes)
865866 require.True(t, wake.reset)
866- require.Same(t, replacement, wake.lease.attachment)
867+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
867868 }
868869
869870 func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
@@ -872,7 +873,10 @@ func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
872873 transition func(*renderCoordinator, *attachedClient, *attachedClient)
873874 }{
874875 {"target detach", func(rc *renderCoordinator, target, _ *attachedClient) { rc.noteDetach(target) }},
875- {"target replacement", func(rc *renderCoordinator, target, replacement *attachedClient) { rc.noteReplace(target, replacement) }},
876+ {"target detach and attach", func(rc *renderCoordinator, target, replacement *attachedClient) {
877+ rc.noteDetach(target)
878+ rc.attach(replacement)
879+ }},
876880 {"target teardown", func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
877881 }
878882 for _, tc := range cases {
@@ -920,7 +924,7 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
920924 teardown func(rc *renderCoordinator, owner *attachedClient)
921925 }{
922926 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
923- {"park", func(rc *renderCoordinator, owner *attachedClient) { rc.notePark(owner) }},
927+ {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
924928 {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
925929 }
926930 for _, tc := range cases {
@@ -955,7 +959,7 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
955959 stale := h.armedTimers(t)
956960 require.NotEmpty(t, stale, "invalidate must arm a deadline timer")
957961
958- h.rc.noteReplace(owner, replacement)
962+ h.rc.noteDetach(owner)
959963 h.rc.attach(replacement)
960964 for _, timer := range stale {
961965 timer.ch <- time.Time{}
@@ -1035,11 +1039,11 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
10351039 case "detach":
10361040 h.rc.noteDetach(staleOwner)
10371041 case "park":
1038- h.rc.notePark(staleOwner)
1042+ h.rc.noteDetach(staleOwner)
10391043 case "session teardown":
10401044 h.rc.beginSessionTeardown().finish()
10411045 case "replacement":
1042- h.rc.noteReplace(staleOwner, freshOwner)
1046+ h.rc.noteDetach(staleOwner)
10431047 h.rc.attach(freshOwner)
10441048 require.Equal(t, uint64(2), h.rc.recordResizeRequest(sz(100, 50), freshOwner))
10451049 }
@@ -1291,41 +1295,6 @@ func requireWorkerExit(t *testing.T, done <-chan struct{}) {
12911295 awaitTestCompletion(t, done, "cancelled coordinator timer worker did not exit")
12921296 }
12931297
1294-func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstall(t *testing.T) {
1295- p, releasePTY := newBlockingPTY(t)
1296- defer releasePTY()
1297- d, sess, owner, ownerSends := newManualSessionWithPTYs(t, p)
1298- replacementTransport, replacementSends := newCapturingTransport(t)
1299- replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: owner.size}
1300- replacement.initOverlays()
1301- replacement.setSession(sess)
1302-
1303- clock := newCoordinatorMockClock(t, 2)
1304- d.clock = clock.clock
1305- rc := newRenderCoordinator(renderCoordinatorOptions{
1306- clock: clock.clock,
1307- wake: func(w renderWake) {
1308- // This is the production ownership boundary: composition must use
1309- // the coordinator's captured attachment, never sess.snapshotAttachments()[0].
1310- d.paint(sess, w.lease.attachment, w.reset, w.lease)
1311- },
1312- ackReady: func() bool { return true },
1313- })
1314- rc.attach(owner)
1315- sess.installRenderCoordinator(rc)
1316- rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
1317- timer := awaitCoordinatorScheduledTimer(t, clock)
1318-
1319- // Model attachClient's publication window exactly: sess.snapshotAttachments()[0] is new,
1320- // but coordinator replacement has not yet invalidated the old deadline.
1321- sess.mu.Lock()
1322- sess.registerAttachmentLocked(replacement)
1323- sess.mu.Unlock()
1324- timer.ch <- time.Time{}
1325- requireNoCoordinatorOutputFrame(t, replacementSends)
1326- requireNoCoordinatorOutputFrame(t, ownerSends)
1327-}
1328-
13291298 func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) {
13301299 rc := newRenderCoordinator(renderCoordinatorOptions{})
13311300 newer := &timerToken{generation: 2, timer: &portsmocks.MockTimer{}, cancel: make(chan struct{}), done: make(chan struct{})}
@@ -1388,7 +1357,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
13881357 run func(*renderCoordinator, *attachedClient)
13891358 }{
13901359 {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1391- {"park", func(rc *renderCoordinator, ac *attachedClient) { rc.notePark(ac) }},
1360+ {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
13921361 } {
13931362 t.Run(transition.name, func(t *testing.T) {
13941363 h := newCoordinatorHarness(t)
@@ -1417,10 +1386,11 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14171386 h.rc.mu.Unlock()
14181387 h.rc.noteSyncEnd(p, 1)
14191388 preview := awaitWake(t, h.previews)
1420- preview.lease = nil
14211389 require.Equal(t, renderWake{urgent: true, coalesced: 1}, preview)
14221390 requireNoWake(t, h.previews)
1423- requireNoWake(t, h.wakes)
1391+ wake := awaitWake(t, h.wakes)
1392+ require.Equal(t, renderWake{urgent: true, coalesced: 1}, wake)
1393+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
14241394 })
14251395 }
14261396 })
@@ -1431,7 +1401,8 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14311401 h.rc.attach(old)
14321402 h.rc.noteSyncBegin(p, 1)
14331403 watchdog := h.armedTimers(t)[0]
1434- h.rc.noteReplace(old, replacement, false)
1404+ h.rc.noteDetach(old)
1405+ h.rc.attachWithReadiness(replacement, false)
14351406 require.False(t, h.rc.markAttachmentReady(h.rc.attachmentLease(old)), "old attachment Welcome is stale")
14361407 require.True(t, h.rc.markAttachmentReady(h.rc.attachmentLease(replacement)))
14371408
@@ -1455,7 +1426,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14551426 wake := awaitWake(t, h.wakes)
14561427 require.True(t, wake.reset)
14571428 require.True(t, wake.urgent)
1458- require.Same(t, replacement, wake.lease.attachment)
1429+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
14591430 requireNoWake(t, h.wakes)
14601431 })
14611432
@@ -181,7 +181,9 @@ func (h *coordinatorHarness) armedTimers(t *testing.T) []*coordinatorMockTimer {
181 // deterministic contract failure, not a slow behavior to poll for.181 // deterministic contract failure, not a slow behavior to poll for.
182 func awaitWake(t *testing.T, ch chan renderWake) renderWake {182 func awaitWake(t *testing.T, ch chan renderWake) renderWake {
183 t.Helper()183 t.Helper()
184- return awaitTestValue(t, ch, "coordinator did not publish a wake after fake-clock advancement")184+ wake := awaitTestValue(t, ch, "coordinator did not publish a wake after fake-clock advancement")
185+ wake.attachmentLeases = nil
186+ return wake
185 }187 }
186 188
187 func requireNoWake(t *testing.T, ch chan renderWake) {189 func requireNoWake(t *testing.T, ch chan renderWake) {
@@ -407,8 +409,7 @@ func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T
407 epoch := awaitTestValue(t, resizeDone, "ack readiness re-entry blocked resize metadata")409 epoch := awaitTestValue(t, resizeDone, "ack readiness re-entry blocked resize metadata")
408 require.Equal(t, uint64(1), epoch)410 require.Equal(t, uint64(1), epoch)
409 wake := awaitWake(t, wakes)411 wake := awaitWake(t, wakes)
410- require.Same(t, owner, wake.lease.attachment)412+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
411- wake.lease = nil
412 require.Equal(t, renderWake{coalesced: 1}, wake)413 require.Equal(t, renderWake{coalesced: 1}, wake)
413 awaitTestCompletion(t, fireDone, "fire did not return after wake")414 awaitTestCompletion(t, fireDone, "fire did not return after wake")
414 }415 }
@@ -749,7 +750,8 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
749 750
750 func TestRenderCoordinatorPreviewSubscription(t *testing.T) {751 func TestRenderCoordinatorPreviewSubscription(t *testing.T) {
751 h := newCoordinatorHarness(t)752 h := newCoordinatorHarness(t)
752- h.rc.subscribePreview(func(w renderWake) { h.previews <- w })753+ viewer := &attachedClient{}
754+ h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
753 755
754 h.rc.invalidate(renderInvalidation{class: invalidateUrgent})756 h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
755 timers := h.armedTimers(t)757 timers := h.armedTimers(t)
@@ -760,7 +762,7 @@ func TestRenderCoordinatorPreviewSubscription(t *testing.T) {
760 require.Equal(t, owner, preview, "a subscribed preview observes the same coalesced wake")762 require.Equal(t, owner, preview, "a subscribed preview observes the same coalesced wake")
761 requireNoWake(t, h.previews)763 requireNoWake(t, h.previews)
762 764
763- h.rc.teardownPreview()765+ h.rc.teardownPreviewFor(viewer, 1)
764 h.rc.invalidate(renderInvalidation{class: invalidateUrgent})766 h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
765 timers = h.armedTimers(t)767 timers = h.armedTimers(t)
766 require.NotEmpty(t, timers, "invalidate must arm a deadline timer")768 require.NotEmpty(t, timers, "invalidate must arm a deadline timer")
@@ -795,24 +797,24 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
795 timers[0].ch <- time.Time{}797 timers[0].ch <- time.Time{}
796 798
797 preview := awaitWake(t, h.previews)799 preview := awaitWake(t, h.previews)
798- if tc.attach {
799- require.NotNil(t, preview.lease.attachment)
800- }
801- preview.lease = nil
802 require.Equal(t, renderWake{coalesced: 1}, preview,800 require.Equal(t, renderWake{coalesced: 1}, preview,
803 "the viewer preview must receive target output without target ACK capacity")801 "the viewer preview must receive target output without target ACK capacity")
804- requireNoWake(t, h.wakes)
805 requireNoWake(t, h.previews)802 requireNoWake(t, h.previews)
803+ if tc.attach {
804+ requireNoWake(t, h.wakes)
805+ } else {
806+ wake := awaitWake(t, h.wakes)
807+ require.Equal(t, renderWake{coalesced: 1}, wake)
808+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
809+ }
806 810
807 h.ackReady.Store(true)811 h.ackReady.Store(true)
808 h.rc.notifyAck()812 h.rc.notifyAck()
809- wake := awaitWake(t, h.wakes)
810 if tc.attach {813 if tc.attach {
811- require.NotNil(t, wake.lease.attachment)814+ wake := awaitWake(t, h.wakes)
815+ require.Equal(t, renderWake{coalesced: 1}, wake,
816+ "the target frame remains pending for its ACK")
812 }817 }
813- wake.lease = nil
814- require.Equal(t, renderWake{coalesced: 1}, wake,
815- "the target primary frame remains pending for its own ACK")
816 requireNoWake(t, h.previews)818 requireNoWake(t, h.previews)
817 })819 })
818 }820 }
@@ -837,9 +839,10 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
837 require.Len(t, timers, 1)839 require.Len(t, timers, 1)
838 timers[0].ch <- time.Time{}840 timers[0].ch <- time.Time{}
839 preview := awaitWake(t, h.previews)841 preview := awaitWake(t, h.previews)
840- require.Nil(t, preview.lease, "headless preview wakes must not retain a revoked primary lease")
841 require.Equal(t, renderWake{coalesced: 1}, preview)842 require.Equal(t, renderWake{coalesced: 1}, preview)
842- requireNoWake(t, h.wakes)843+ wake := awaitWake(t, h.wakes)
844+ require.Equal(t, renderWake{coalesced: 1}, wake)
845+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
843 846
844 h.rc.mu.Lock()847 h.rc.mu.Lock()
845 require.False(t, h.rc.pending)848 require.False(t, h.rc.pending)
@@ -847,9 +850,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
847 require.False(t, h.rc.ackDeferred)850 require.False(t, h.rc.ackDeferred)
848 require.False(t, h.rc.deadlineDue)851 require.False(t, h.rc.deadlineDue)
849 require.Zero(t, h.rc.coalesced)852 require.Zero(t, h.rc.coalesced)
850- require.True(t, h.rc.primaryDetachedLocked())853+ require.Empty(t, h.rc.attachments)
851- require.NotNil(t, h.rc.lease)
852- require.False(t, h.rc.lease.active)
853 h.rc.mu.Unlock()854 h.rc.mu.Unlock()
854 }855 }
855 856
@@ -863,7 +864,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
863 timers[0].ch <- time.Time{}864 timers[0].ch <- time.Time{}
864 wake := awaitWake(t, h.wakes)865 wake := awaitWake(t, h.wakes)
865 require.True(t, wake.reset)866 require.True(t, wake.reset)
866- require.Same(t, replacement, wake.lease.attachment)867+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
867 }868 }
868 869
869 func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {870 func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
@@ -872,7 +873,10 @@ func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
872 transition func(*renderCoordinator, *attachedClient, *attachedClient)873 transition func(*renderCoordinator, *attachedClient, *attachedClient)
873 }{874 }{
874 {"target detach", func(rc *renderCoordinator, target, _ *attachedClient) { rc.noteDetach(target) }},875 {"target detach", func(rc *renderCoordinator, target, _ *attachedClient) { rc.noteDetach(target) }},
875- {"target replacement", func(rc *renderCoordinator, target, replacement *attachedClient) { rc.noteReplace(target, replacement) }},876+ {"target detach and attach", func(rc *renderCoordinator, target, replacement *attachedClient) {
877+ rc.noteDetach(target)
878+ rc.attach(replacement)
879+ }},
876 {"target teardown", func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.beginSessionTeardown().finish() }},880 {"target teardown", func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
877 }881 }
878 for _, tc := range cases {882 for _, tc := range cases {
@@ -920,7 +924,7 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
920 teardown func(rc *renderCoordinator, owner *attachedClient)924 teardown func(rc *renderCoordinator, owner *attachedClient)
921 }{925 }{
922 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},926 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
923- {"park", func(rc *renderCoordinator, owner *attachedClient) { rc.notePark(owner) }},927+ {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
924 {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},928 {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
925 }929 }
926 for _, tc := range cases {930 for _, tc := range cases {
@@ -955,7 +959,7 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
955 stale := h.armedTimers(t)959 stale := h.armedTimers(t)
956 require.NotEmpty(t, stale, "invalidate must arm a deadline timer")960 require.NotEmpty(t, stale, "invalidate must arm a deadline timer")
957 961
958- h.rc.noteReplace(owner, replacement)962+ h.rc.noteDetach(owner)
959 h.rc.attach(replacement)963 h.rc.attach(replacement)
960 for _, timer := range stale {964 for _, timer := range stale {
961 timer.ch <- time.Time{}965 timer.ch <- time.Time{}
@@ -1035,11 +1039,11 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
1035 case "detach":1039 case "detach":
1036 h.rc.noteDetach(staleOwner)1040 h.rc.noteDetach(staleOwner)
1037 case "park":1041 case "park":
1038- h.rc.notePark(staleOwner)1042+ h.rc.noteDetach(staleOwner)
1039 case "session teardown":1043 case "session teardown":
1040 h.rc.beginSessionTeardown().finish()1044 h.rc.beginSessionTeardown().finish()
1041 case "replacement":1045 case "replacement":
1042- h.rc.noteReplace(staleOwner, freshOwner)1046+ h.rc.noteDetach(staleOwner)
1043 h.rc.attach(freshOwner)1047 h.rc.attach(freshOwner)
1044 require.Equal(t, uint64(2), h.rc.recordResizeRequest(sz(100, 50), freshOwner))1048 require.Equal(t, uint64(2), h.rc.recordResizeRequest(sz(100, 50), freshOwner))
1045 }1049 }
@@ -1291,41 +1295,6 @@ func requireWorkerExit(t *testing.T, done <-chan struct{}) {
1291 awaitTestCompletion(t, done, "cancelled coordinator timer worker did not exit")1295 awaitTestCompletion(t, done, "cancelled coordinator timer worker did not exit")
1292 }1296 }
1293 1297
1294-func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstall(t *testing.T) {
1295- p, releasePTY := newBlockingPTY(t)
1296- defer releasePTY()
1297- d, sess, owner, ownerSends := newManualSessionWithPTYs(t, p)
1298- replacementTransport, replacementSends := newCapturingTransport(t)
1299- replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: owner.size}
1300- replacement.initOverlays()
1301- replacement.setSession(sess)
1302-
1303- clock := newCoordinatorMockClock(t, 2)
1304- d.clock = clock.clock
1305- rc := newRenderCoordinator(renderCoordinatorOptions{
1306- clock: clock.clock,
1307- wake: func(w renderWake) {
1308- // This is the production ownership boundary: composition must use
1309- // the coordinator's captured attachment, never sess.snapshotAttachments()[0].
1310- d.paint(sess, w.lease.attachment, w.reset, w.lease)
1311- },
1312- ackReady: func() bool { return true },
1313- })
1314- rc.attach(owner)
1315- sess.installRenderCoordinator(rc)
1316- rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
1317- timer := awaitCoordinatorScheduledTimer(t, clock)
1318-
1319- // Model attachClient's publication window exactly: sess.snapshotAttachments()[0] is new,
1320- // but coordinator replacement has not yet invalidated the old deadline.
1321- sess.mu.Lock()
1322- sess.registerAttachmentLocked(replacement)
1323- sess.mu.Unlock()
1324- timer.ch <- time.Time{}
1325- requireNoCoordinatorOutputFrame(t, replacementSends)
1326- requireNoCoordinatorOutputFrame(t, ownerSends)
1327-}
1328-
1329 func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) {1298 func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) {
1330 rc := newRenderCoordinator(renderCoordinatorOptions{})1299 rc := newRenderCoordinator(renderCoordinatorOptions{})
1331 newer := &timerToken{generation: 2, timer: &portsmocks.MockTimer{}, cancel: make(chan struct{}), done: make(chan struct{})}1300 newer := &timerToken{generation: 2, timer: &portsmocks.MockTimer{}, cancel: make(chan struct{}), done: make(chan struct{})}
@@ -1388,7 +1357,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1388 run func(*renderCoordinator, *attachedClient)1357 run func(*renderCoordinator, *attachedClient)
1389 }{1358 }{
1390 {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},1359 {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1391- {"park", func(rc *renderCoordinator, ac *attachedClient) { rc.notePark(ac) }},1360+ {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1392 } {1361 } {
1393 t.Run(transition.name, func(t *testing.T) {1362 t.Run(transition.name, func(t *testing.T) {
1394 h := newCoordinatorHarness(t)1363 h := newCoordinatorHarness(t)
@@ -1417,10 +1386,11 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1417 h.rc.mu.Unlock()1386 h.rc.mu.Unlock()
1418 h.rc.noteSyncEnd(p, 1)1387 h.rc.noteSyncEnd(p, 1)
1419 preview := awaitWake(t, h.previews)1388 preview := awaitWake(t, h.previews)
1420- preview.lease = nil
1421 require.Equal(t, renderWake{urgent: true, coalesced: 1}, preview)1389 require.Equal(t, renderWake{urgent: true, coalesced: 1}, preview)
1422 requireNoWake(t, h.previews)1390 requireNoWake(t, h.previews)
1423- requireNoWake(t, h.wakes)1391+ wake := awaitWake(t, h.wakes)
1392+ require.Equal(t, renderWake{urgent: true, coalesced: 1}, wake)
1393+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
1424 })1394 })
1425 }1395 }
1426 })1396 })
@@ -1431,7 +1401,8 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1431 h.rc.attach(old)1401 h.rc.attach(old)
1432 h.rc.noteSyncBegin(p, 1)1402 h.rc.noteSyncBegin(p, 1)
1433 watchdog := h.armedTimers(t)[0]1403 watchdog := h.armedTimers(t)[0]
1434- h.rc.noteReplace(old, replacement, false)1404+ h.rc.noteDetach(old)
1405+ h.rc.attachWithReadiness(replacement, false)
1435 require.False(t, h.rc.markAttachmentReady(h.rc.attachmentLease(old)), "old attachment Welcome is stale")1406 require.False(t, h.rc.markAttachmentReady(h.rc.attachmentLease(old)), "old attachment Welcome is stale")
1436 require.True(t, h.rc.markAttachmentReady(h.rc.attachmentLease(replacement)))1407 require.True(t, h.rc.markAttachmentReady(h.rc.attachmentLease(replacement)))
1437 1408
@@ -1455,7 +1426,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1455 wake := awaitWake(t, h.wakes)1426 wake := awaitWake(t, h.wakes)
1456 require.True(t, wake.reset)1427 require.True(t, wake.reset)
1457 require.True(t, wake.urgent)1428 require.True(t, wake.urgent)
1458- require.Same(t, replacement, wake.lease.attachment)1429+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
1459 requireNoWake(t, h.wakes)1430 requireNoWake(t, h.wakes)
1460 })1431 })
1461 1432
M internal/usecase/daemon/render_coordinator_types.go +9 -7
@@ -51,17 +51,19 @@ type renderWake struct {
5151 reset bool
5252 urgent bool
5353 coalesced int
54- // lease is snapshotted under coordinator ownership. The wake consumer must
55- // compose only for this exact attachment incarnation, never by rereading
56- // mutable session membership after attachment publication.
57- lease *attachmentLease
54+ // lease is optional coordinator-owned lifecycle safety for an attachment
55+ // specific wake. Shared wakes leave it nil and snapshot live attachment
56+ // leases at dispatch time; each paint then revalidates its exact connection
57+ // incarnation.
58+ lease *attachmentLease
59+ attachmentLeases map[*attachedClient]*attachmentLease
5860 // watchdog marks a flush forced by the synchronized-output watchdog.
5961 watchdog bool
6062 }
6163
62-// attachmentLease is the sole lifecycle identity for a primary render
63-// attachment. It is only mutated while renderCoordinator.mu is held; callbacks
64-// carry its pointer and must revalidate it before every side effect.
64+// attachmentLease is the lifecycle identity for one render attachment. It is
65+// only mutated while renderCoordinator.mu is held; callbacks carry its pointer
66+// and must revalidate it before every side effect.
6567 type attachmentLease struct {
6668 attachment *attachedClient
6769 ready bool
@@ -51,17 +51,19 @@ type renderWake struct {
51 reset bool51 reset bool
52 urgent bool52 urgent bool
53 coalesced int53 coalesced int
54- // lease is snapshotted under coordinator ownership. The wake consumer must54+ // lease is optional coordinator-owned lifecycle safety for an attachment
55- // compose only for this exact attachment incarnation, never by rereading55+ // specific wake. Shared wakes leave it nil and snapshot live attachment
56- // mutable session membership after attachment publication.56+ // leases at dispatch time; each paint then revalidates its exact connection
57- lease *attachmentLease57+ // incarnation.
58+ lease *attachmentLease
59+ attachmentLeases map[*attachedClient]*attachmentLease
58 // watchdog marks a flush forced by the synchronized-output watchdog.60 // watchdog marks a flush forced by the synchronized-output watchdog.
59 watchdog bool61 watchdog bool
60 }62 }
61 63
62-// attachmentLease is the sole lifecycle identity for a primary render64+// attachmentLease is the lifecycle identity for one render attachment. It is
63-// attachment. It is only mutated while renderCoordinator.mu is held; callbacks65+// only mutated while renderCoordinator.mu is held; callbacks carry its pointer
64-// carry its pointer and must revalidate it before every side effect.66+// and must revalidate it before every side effect.
65 type attachmentLease struct {67 type attachmentLease struct {
66 attachment *attachedClient68 attachment *attachedClient
67 ready bool69 ready bool
M internal/usecase/daemon/render_damage_receipt_test.go +3 -3
@@ -27,7 +27,7 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
2727 p.mu.Unlock()
2828
2929 ac.sendMu.Lock()
30- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
30+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{
3131 bars: barState{},
3232 overlays: capturedOverlayRenderState{},
3333 preview: pickerPreviewEmpty(),
@@ -61,7 +61,7 @@ func TestCaptureComposeEmitAcknowledgesCollapsedPaneDamageOnlyAfterEmission(t *t
6161 collapsed.screen.Write([]byte("hidden damage"))
6262
6363 ac.sendMu.Lock()
64- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: barState{}})
64+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{bars: barState{}})
6565 require.True(t, ok)
6666 collapsedReceipt := false
6767 for _, receipt := range state.receipts {
@@ -223,7 +223,7 @@ func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
223223 func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {
224224 t.Helper()
225225 ac.sendMu.Lock() // emitFrame releases the transaction lock.
226- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
226+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{
227227 bars: barState{},
228228 overlays: capturedOverlayRenderState{},
229229 preview: pickerPreviewEmpty(),
@@ -27,7 +27,7 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
27 p.mu.Unlock()27 p.mu.Unlock()
28 28
29 ac.sendMu.Lock()29 ac.sendMu.Lock()
30- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{30+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{
31 bars: barState{},31 bars: barState{},
32 overlays: capturedOverlayRenderState{},32 overlays: capturedOverlayRenderState{},
33 preview: pickerPreviewEmpty(),33 preview: pickerPreviewEmpty(),
@@ -61,7 +61,7 @@ func TestCaptureComposeEmitAcknowledgesCollapsedPaneDamageOnlyAfterEmission(t *t
61 collapsed.screen.Write([]byte("hidden damage"))61 collapsed.screen.Write([]byte("hidden damage"))
62 62
63 ac.sendMu.Lock()63 ac.sendMu.Lock()
64- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: barState{}})64+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{bars: barState{}})
65 require.True(t, ok)65 require.True(t, ok)
66 collapsedReceipt := false66 collapsedReceipt := false
67 for _, receipt := range state.receipts {67 for _, receipt := range state.receipts {
@@ -223,7 +223,7 @@ func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
223 func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {223 func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {
224 t.Helper()224 t.Helper()
225 ac.sendMu.Lock() // emitFrame releases the transaction lock.225 ac.sendMu.Lock() // emitFrame releases the transaction lock.
226- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{226+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{
227 bars: barState{},227 bars: barState{},
228 overlays: capturedOverlayRenderState{},228 overlays: capturedOverlayRenderState{},
229 preview: pickerPreviewEmpty(),229 preview: pickerPreviewEmpty(),
M internal/usecase/daemon/render_pipeline.go +3 -3
@@ -59,7 +59,7 @@ type composedRenderFrame struct {
5959
6060 // composeFrame is pure with respect to daemon ownership: it consumes only the
6161 // capture, the last committed cache, and an independent attachment-owned
62-// scratch cache. It returns a replacement cache without mutating committed.
62+// scratch cache. It returns a new cache without mutating committed.
6363 const inactivePaneForegroundDimming = 55
6464
6565 func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
@@ -619,8 +619,8 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
619619 }
620620 } else {
621621 // Capture the exact admitted capability, including its coordinator
622- // lease, before End permits a replacement publication. Reserve cleanup
623- // accounting before End so terminal Wait cannot race a later Add.
622+ // lease, before End permits a new attachment publication. Reserve
623+ // cleanup accounting before End so terminal Wait cannot race a later Add.
624624 token := marks.attachmentEffect.connectionToken()
625625 launchCleanup := d.reserveAttachmentSendErrorCleanup(token, sendTr)
626626 marks.attachmentEffect.End()
@@ -59,7 +59,7 @@ type composedRenderFrame struct {
59 59
60 // composeFrame is pure with respect to daemon ownership: it consumes only the60 // composeFrame is pure with respect to daemon ownership: it consumes only the
61 // capture, the last committed cache, and an independent attachment-owned61 // capture, the last committed cache, and an independent attachment-owned
62-// scratch cache. It returns a replacement cache without mutating committed.62+// scratch cache. It returns a new cache without mutating committed.
63 const inactivePaneForegroundDimming = 5563 const inactivePaneForegroundDimming = 55
64 64
65 func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {65 func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
@@ -619,8 +619,8 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
619 }619 }
620 } else {620 } else {
621 // Capture the exact admitted capability, including its coordinator621 // Capture the exact admitted capability, including its coordinator
622- // lease, before End permits a replacement publication. Reserve cleanup622+ // lease, before End permits a new attachment publication. Reserve
623- // accounting before End so terminal Wait cannot race a later Add.623+ // cleanup accounting before End so terminal Wait cannot race a later Add.
624 token := marks.attachmentEffect.connectionToken()624 token := marks.attachmentEffect.connectionToken()
625 launchCleanup := d.reserveAttachmentSendErrorCleanup(token, sendTr)625 launchCleanup := d.reserveAttachmentSendErrorCleanup(token, sendTr)
626 marks.attachmentEffect.End()626 marks.attachmentEffect.End()
M internal/usecase/daemon/render_test.go +25 -28
@@ -523,45 +523,42 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
523523 d.sessWg.Wait()
524524 })
525525
526- t.Run("replacement receives one complete reset frame without later PTY output", func(t *testing.T) {
526+ t.Run("remaining attachment receives shared output after peer detach", func(t *testing.T) {
527527 pty, steps, processed := newChannelPTY(t)
528- d, target, oldClient, _ := newManualSessionWithPTYs(t, pty)
528+ d, target, detached, _ := newManualSessionWithPTYs(t, pty)
529529 clock := newCoordinatorMockClock(t, 8)
530530 d.clock = clock.clock
531- rc := d.attachCoordinator(target, nil, oldClient, true)
531+ rc := d.attachCoordinator(target, nil, detached, true)
532+
533+ secondTransport, secondSends := newCapturingTransport(t)
534+ remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size}
535+ remaining.initOverlays()
536+ remaining.setSession(target)
537+ target.mu.Lock()
538+ require.True(t, target.registerAttachmentLocked(remaining))
539+ target.mu.Unlock()
540+ rc.attach(remaining)
532541
533- wakes := make(chan renderWake, 2)
534- rc.opts.wake = func(w renderWake) { wakes <- w }
535542 d.sessWg.Add(1)
536543 go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
537- steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}
544+ steps <- channelPTYStep{data: []byte("before detach")}
538545 awaitPTYReadProcessed(t, processed)
539- drainCoordinatorTimers(clock)
546+ timers := drainCoordinatorTimers(clock)
540547
541- replacement := &attachedClient{output: newOutputStateStream()}
542- rc.noteReplace(oldClient, replacement, true)
548+ // Detaching one attachment must not discard the session-shared pending
549+ // invalidation or stop the coordinator from painting the remaining one.
543550 target.mu.Lock()
544- target.registerAttachmentLocked(replacement)
551+ require.True(t, target.unregisterAttachmentLocked(detached))
545552 target.mu.Unlock()
546- // The replacement's initial full paint remains gated by the batch. Its
547- // reset must survive completion and coalesce with the completion wake.
548- rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"})
549- replacementTimers := drainCoordinatorTimers(clock)
550- steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
551- awaitPTYReadProcessed(t, processed)
552- requireNoWake(t, wakes)
553+ rc.noteDetach(detached)
554+ fireCoordinatorTimer(t, rc, timers, minOutputRenderDeadline)
555+ require.NotEmpty(t, secondSends, "the remaining attachment received no shared output after its peer detached")
556+ before := len(secondSends)
553557
554- fireCoordinatorTimer(t, rc, replacementTimers, urgentRenderDeadline)
555- wake := <-wakes
556- require.True(t, wake.urgent)
557- require.True(t, wake.reset, "the replacement's cleared batch must repaint a complete frame")
558- require.Equal(t, 2, wake.coalesced, "completion coalesces only with the replacement's fresh reset batch")
559- require.Same(t, replacement, wake.lease.attachment)
560- select {
561- case duplicate := <-wakes:
562- t.Fatalf("sync completion must publish exactly one replacement wake: %#v", duplicate)
563- default:
564- }
558+ steps <- channelPTYStep{data: []byte("after detach")}
559+ awaitPTYReadProcessed(t, processed)
560+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
561+ require.Greater(t, len(secondSends), before, "shared output stopped after the peer detached")
565562
566563 steps <- channelPTYStep{err: io.EOF}
567564 d.sessWg.Wait()
@@ -523,45 +523,42 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
523 d.sessWg.Wait()523 d.sessWg.Wait()
524 })524 })
525 525
526- t.Run("replacement receives one complete reset frame without later PTY output", func(t *testing.T) {526+ t.Run("remaining attachment receives shared output after peer detach", func(t *testing.T) {
527 pty, steps, processed := newChannelPTY(t)527 pty, steps, processed := newChannelPTY(t)
528- d, target, oldClient, _ := newManualSessionWithPTYs(t, pty)528+ d, target, detached, _ := newManualSessionWithPTYs(t, pty)
529 clock := newCoordinatorMockClock(t, 8)529 clock := newCoordinatorMockClock(t, 8)
530 d.clock = clock.clock530 d.clock = clock.clock
531- rc := d.attachCoordinator(target, nil, oldClient, true)531+ rc := d.attachCoordinator(target, nil, detached, true)
532+
533+ secondTransport, secondSends := newCapturingTransport(t)
534+ remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size}
535+ remaining.initOverlays()
536+ remaining.setSession(target)
537+ target.mu.Lock()
538+ require.True(t, target.registerAttachmentLocked(remaining))
539+ target.mu.Unlock()
540+ rc.attach(remaining)
532 541
533- wakes := make(chan renderWake, 2)
534- rc.opts.wake = func(w renderWake) { wakes <- w }
535 d.sessWg.Add(1)542 d.sessWg.Add(1)
536 go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())543 go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
537- steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}544+ steps <- channelPTYStep{data: []byte("before detach")}
538 awaitPTYReadProcessed(t, processed)545 awaitPTYReadProcessed(t, processed)
539- drainCoordinatorTimers(clock)546+ timers := drainCoordinatorTimers(clock)
540 547
541- replacement := &attachedClient{output: newOutputStateStream()}548+ // Detaching one attachment must not discard the session-shared pending
542- rc.noteReplace(oldClient, replacement, true)549+ // invalidation or stop the coordinator from painting the remaining one.
543 target.mu.Lock()550 target.mu.Lock()
544- target.registerAttachmentLocked(replacement)551+ require.True(t, target.unregisterAttachmentLocked(detached))
545 target.mu.Unlock()552 target.mu.Unlock()
546- // The replacement's initial full paint remains gated by the batch. Its553+ rc.noteDetach(detached)
547- // reset must survive completion and coalesce with the completion wake.554+ fireCoordinatorTimer(t, rc, timers, minOutputRenderDeadline)
548- rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"})555+ require.NotEmpty(t, secondSends, "the remaining attachment received no shared output after its peer detached")
549- replacementTimers := drainCoordinatorTimers(clock)556+ before := len(secondSends)
550- steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
551- awaitPTYReadProcessed(t, processed)
552- requireNoWake(t, wakes)
553 557
554- fireCoordinatorTimer(t, rc, replacementTimers, urgentRenderDeadline)558+ steps <- channelPTYStep{data: []byte("after detach")}
555- wake := <-wakes559+ awaitPTYReadProcessed(t, processed)
556- require.True(t, wake.urgent)560+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
557- require.True(t, wake.reset, "the replacement's cleared batch must repaint a complete frame")561+ require.Greater(t, len(secondSends), before, "shared output stopped after the peer detached")
558- require.Equal(t, 2, wake.coalesced, "completion coalesces only with the replacement's fresh reset batch")
559- require.Same(t, replacement, wake.lease.attachment)
560- select {
561- case duplicate := <-wakes:
562- t.Fatalf("sync completion must publish exactly one replacement wake: %#v", duplicate)
563- default:
564- }
565 562
566 steps <- channelPTYStep{err: io.EOF}563 steps <- channelPTYStep{err: io.EOF}
567 d.sessWg.Wait()564 d.sessWg.Wait()
M internal/usecase/daemon/render_timer.go +1 -1
@@ -81,7 +81,7 @@ func (s *timerSupervisor) startLocked(token *timerToken, timerC <-chan time.Time
8181 func (s *timerSupervisor) wait() { s.workers.Wait() }
8282
8383 // stopDetachedTimer is intentionally non-blocking. Ordinary lifecycle and
84-// replacement transitions may cancel/stop stale workers but callback stacks
84+// attachment transitions may cancel/stop stale workers but callback stacks
8585 // never wait for a worker to finish.
8686 func stopDetachedTimer(token *timerToken) {
8787 if token != nil && token.timer != nil {
@@ -81,7 +81,7 @@ func (s *timerSupervisor) startLocked(token *timerToken, timerC <-chan time.Time
81 func (s *timerSupervisor) wait() { s.workers.Wait() }81 func (s *timerSupervisor) wait() { s.workers.Wait() }
82 82
83 // stopDetachedTimer is intentionally non-blocking. Ordinary lifecycle and83 // stopDetachedTimer is intentionally non-blocking. Ordinary lifecycle and
84-// replacement transitions may cancel/stop stale workers but callback stacks84+// attachment transitions may cancel/stop stale workers but callback stacks
85 // never wait for a worker to finish.85 // never wait for a worker to finish.
86 func stopDetachedTimer(token *timerToken) {86 func stopDetachedTimer(token *timerToken) {
87 if token != nil && token.timer != nil {87 if token != nil && token.timer != nil {
M internal/usecase/daemon/resize_mode.go +4 -2
@@ -131,8 +131,10 @@ func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) {
131131 return
132132 }
133133 for _, request := range requests {
134- request.target = resolveDaemonActionTargetForAttachment(sess, ac)
135- if err := (daemonActions{d: d}).Run(request); err != nil {
134+ if err := sess.runMutation(func() error {
135+ request.target = resolveDaemonActionTargetForAttachment(sess, ac)
136+ return (daemonActions{d: d}).Run(request)
137+ }); err != nil {
136138 d.reportError(sess, resizeUserError(err))
137139 continue
138140 }
@@ -131,8 +131,10 @@ func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) {
131 return131 return
132 }132 }
133 for _, request := range requests {133 for _, request := range requests {
134- request.target = resolveDaemonActionTargetForAttachment(sess, ac)134+ if err := sess.runMutation(func() error {
135- if err := (daemonActions{d: d}).Run(request); err != nil {135+ request.target = resolveDaemonActionTargetForAttachment(sess, ac)
136+ return (daemonActions{d: d}).Run(request)
137+ }); err != nil {
136 d.reportError(sess, resizeUserError(err))138 d.reportError(sess, resizeUserError(err))
137 continue139 continue
138 }140 }
M internal/usecase/daemon/session_core.go +3 -3
@@ -13,7 +13,7 @@ type attachmentSession interface {
1313 core() *sessionCore
1414 snapshotView(viewOptions) sessionView
1515 statusSegments(includeTerminalTitle bool) statusSnapshot
16- capturePrimary(*attachedClient, primaryCaptureRequest) (*capturedRenderState, bool)
16+ captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool)
1717 validTargetTabLocked(tabIndex int) bool
1818 isProxy() bool
1919 }
@@ -49,8 +49,8 @@ func (s *session) core() *sessionCore {
4949
5050 func (s *session) isProxy() bool { return false }
5151
52-func (s *session) capturePrimary(ac *attachedClient, req primaryCaptureRequest) (*capturedRenderState, bool) {
53- return captureLocalPrimaryRenderState(s, ac, req)
52+func (s *session) captureRenderState(ac *attachedClient, req renderCaptureRequest) (*capturedRenderState, bool) {
53+ return captureLocalRenderState(s, ac, req)
5454 }
5555
5656 // validTargetTabLocked validates a target tab index without changing any
@@ -13,7 +13,7 @@ type attachmentSession interface {
13 core() *sessionCore13 core() *sessionCore
14 snapshotView(viewOptions) sessionView14 snapshotView(viewOptions) sessionView
15 statusSegments(includeTerminalTitle bool) statusSnapshot15 statusSegments(includeTerminalTitle bool) statusSnapshot
16- capturePrimary(*attachedClient, primaryCaptureRequest) (*capturedRenderState, bool)16+ captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool)
17 validTargetTabLocked(tabIndex int) bool17 validTargetTabLocked(tabIndex int) bool
18 isProxy() bool18 isProxy() bool
19 }19 }
@@ -49,8 +49,8 @@ func (s *session) core() *sessionCore {
49 49
50 func (s *session) isProxy() bool { return false }50 func (s *session) isProxy() bool { return false }
51 51
52-func (s *session) capturePrimary(ac *attachedClient, req primaryCaptureRequest) (*capturedRenderState, bool) {52+func (s *session) captureRenderState(ac *attachedClient, req renderCaptureRequest) (*capturedRenderState, bool) {
53- return captureLocalPrimaryRenderState(s, ac, req)53+ return captureLocalRenderState(s, ac, req)
54 }54 }
55 55
56 // validTargetTabLocked validates a target tab index without changing any56 // validTargetTabLocked validates a target tab index without changing any
M internal/usecase/daemon/session_test.go +6 -3
@@ -1574,13 +1574,16 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
15741574 got := ac.currentSession()
15751575 require.NotNil(t, got)
15761576 sourceCoordinator.mu.Lock()
1577- require.NotNil(t, sourceCoordinator.lease)
1578- require.False(t, sourceCoordinator.lease.active, "source callbacks must be stale after handoff")
1577+ require.Empty(t, sourceCoordinator.attachments, "source callbacks must be stale after handoff")
1578+ require.Empty(t, sourceCoordinator.leases, "source leases must be retired after handoff")
15791579 sourceCoordinator.mu.Unlock()
15801580 destinationCoordinator := got.renderCoordinator()
15811581 require.NotNil(t, destinationCoordinator, "destination coordinator precedes first paint")
15821582 destinationCoordinator.mu.Lock()
1583- require.Same(t, ac, destinationCoordinator.lease.attachment)
1583+ require.Contains(t, destinationCoordinator.attachments, ac)
1584+ lease := destinationCoordinator.leases[ac]
1585+ require.NotNil(t, lease)
1586+ require.Same(t, ac, lease.attachment)
15841587 destinationCoordinator.mu.Unlock()
15851588 ac.sendMu.Lock()
15861589 require.Equal(t, uint64(1), ac.output.next-ac.output.acked, "only the destination first paint may follow the rebase")
@@ -1574,13 +1574,16 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1574 got := ac.currentSession()1574 got := ac.currentSession()
1575 require.NotNil(t, got)1575 require.NotNil(t, got)
1576 sourceCoordinator.mu.Lock()1576 sourceCoordinator.mu.Lock()
1577- require.NotNil(t, sourceCoordinator.lease)1577+ require.Empty(t, sourceCoordinator.attachments, "source callbacks must be stale after handoff")
1578- require.False(t, sourceCoordinator.lease.active, "source callbacks must be stale after handoff")1578+ require.Empty(t, sourceCoordinator.leases, "source leases must be retired after handoff")
1579 sourceCoordinator.mu.Unlock()1579 sourceCoordinator.mu.Unlock()
1580 destinationCoordinator := got.renderCoordinator()1580 destinationCoordinator := got.renderCoordinator()
1581 require.NotNil(t, destinationCoordinator, "destination coordinator precedes first paint")1581 require.NotNil(t, destinationCoordinator, "destination coordinator precedes first paint")
1582 destinationCoordinator.mu.Lock()1582 destinationCoordinator.mu.Lock()
1583- require.Same(t, ac, destinationCoordinator.lease.attachment)1583+ require.Contains(t, destinationCoordinator.attachments, ac)
1584+ lease := destinationCoordinator.leases[ac]
1585+ require.NotNil(t, lease)
1586+ require.Same(t, ac, lease.attachment)
1584 destinationCoordinator.mu.Unlock()1587 destinationCoordinator.mu.Unlock()
1585 ac.sendMu.Lock()1588 ac.sendMu.Lock()
1586 require.Equal(t, uint64(1), ac.output.next-ac.output.acked, "only the destination first paint may follow the rebase")1589 require.Equal(t, uint64(1), ac.output.next-ac.output.acked, "only the destination first paint may follow the rebase")
M internal/usecase/daemon/snapshot_resume_publication_test.go +0 -59
@@ -141,65 +141,6 @@ func cloneCheckpointRef(ref *domain.CheckpointRef) *domain.CheckpointRef {
141141 return &clone
142142 }
143143
144-func TestNamedSessionPublishesNextCheckpointAfterClientSnatch(t *testing.T) {
145- pty, release := newBlockingPTY(t)
146- repository := newSnapshotLifecycleRepository(t)
147- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
148- WithSnapshotRepository(repository)(d)
149- store, _ := newMockStore(t)
150- WithStore(t, store)(d)
151- startSnapshotEncodeWorker(t, d)
152- t.Cleanup(func() {
153- d.snapsEnabled = false
154- release()
155- d.sessWg.Wait()
156- })
157-
158- firstTransport := &closeTrackingTransport{}
159- sess, firstClient, err := d.route(snapshotLifecycleHello(ports.IntentNew, "work"), firstTransport)
160- require.NoError(t, err)
161-
162- testAttachmentTab(sess).focusedPane().screen.Write([]byte("first checkpoint"))
163- markSnapshotDirty(sess)
164- require.True(t, d.scheduleSnapshot(sess))
165- awaitSnapshotIdle(t, sess)
166- d.snapshotNoticeMu.Lock()
167- firstFailure := d.snapshotActiveFailureSignature
168- d.snapshotNoticeMu.Unlock()
169- attempts, publicationErrors := repository.results()
170- require.Len(t, attempts, 1)
171- require.NoError(t, publicationErrors[0])
172- require.Empty(t, firstFailure)
173- require.False(t, sess.snapDirty.Load())
174- firstRecord := snapshotLifecycleRecord(t, d, sess.name)
175- require.NotNil(t, firstRecord.Committed)
176- require.Equal(t, uint64(1), firstRecord.Committed.Generation)
177-
178- secondTransport := &closeTrackingTransport{}
179- snatchedSession, secondClient, err := d.route(snapshotLifecycleHello(ports.IntentAttach, "work"), secondTransport)
180- require.NoError(t, err)
181- require.Same(t, sess, snatchedSession, "client snatch must retain the live session")
182- require.NotSame(t, firstClient, secondClient, "client B must displace client A")
183-
184- testAttachmentTab(sess).focusedPane().screen.Write([]byte("second checkpoint"))
185- markSnapshotDirty(sess)
186- require.True(t, d.scheduleFinalSnapshot(sess))
187- awaitSnapshotIdle(t, sess)
188- attempts, publicationErrors = repository.results()
189- require.Len(t, attempts, 2)
190- require.NoError(t, publicationErrors[1])
191- require.False(t, sess.snapDirty.Load())
192-
193- secondRecord := snapshotLifecycleRecord(t, d, sess.name)
194- require.NotNil(t, secondRecord.Committed)
195- require.Equal(t, firstRecord.Committed.Generation+1, secondRecord.Committed.Generation,
196- "client snatch must not reset or conflict with the repository generation")
197- generation, err := repository.LoadCheckpoint(context.Background(), sess.incarnation, sess.name, *secondRecord.Committed)
198- require.NoError(t, err)
199- require.Equal(t, firstRecord.Committed, generation.ParentCheckpoint,
200- "the post-snatch checkpoint must retain the committed parent")
201-}
202-
203144 func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing.T) {
204145 keeperPTY, releaseKeeper := newBlockingPTY(t)
205146 firstPTY, releaseFirst := newBlockingPTY(t)
@@ -141,65 +141,6 @@ func cloneCheckpointRef(ref *domain.CheckpointRef) *domain.CheckpointRef {
141 return &clone141 return &clone
142 }142 }
143 143
144-func TestNamedSessionPublishesNextCheckpointAfterClientSnatch(t *testing.T) {
145- pty, release := newBlockingPTY(t)
146- repository := newSnapshotLifecycleRepository(t)
147- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
148- WithSnapshotRepository(repository)(d)
149- store, _ := newMockStore(t)
150- WithStore(t, store)(d)
151- startSnapshotEncodeWorker(t, d)
152- t.Cleanup(func() {
153- d.snapsEnabled = false
154- release()
155- d.sessWg.Wait()
156- })
157-
158- firstTransport := &closeTrackingTransport{}
159- sess, firstClient, err := d.route(snapshotLifecycleHello(ports.IntentNew, "work"), firstTransport)
160- require.NoError(t, err)
161-
162- testAttachmentTab(sess).focusedPane().screen.Write([]byte("first checkpoint"))
163- markSnapshotDirty(sess)
164- require.True(t, d.scheduleSnapshot(sess))
165- awaitSnapshotIdle(t, sess)
166- d.snapshotNoticeMu.Lock()
167- firstFailure := d.snapshotActiveFailureSignature
168- d.snapshotNoticeMu.Unlock()
169- attempts, publicationErrors := repository.results()
170- require.Len(t, attempts, 1)
171- require.NoError(t, publicationErrors[0])
172- require.Empty(t, firstFailure)
173- require.False(t, sess.snapDirty.Load())
174- firstRecord := snapshotLifecycleRecord(t, d, sess.name)
175- require.NotNil(t, firstRecord.Committed)
176- require.Equal(t, uint64(1), firstRecord.Committed.Generation)
177-
178- secondTransport := &closeTrackingTransport{}
179- snatchedSession, secondClient, err := d.route(snapshotLifecycleHello(ports.IntentAttach, "work"), secondTransport)
180- require.NoError(t, err)
181- require.Same(t, sess, snatchedSession, "client snatch must retain the live session")
182- require.NotSame(t, firstClient, secondClient, "client B must displace client A")
183-
184- testAttachmentTab(sess).focusedPane().screen.Write([]byte("second checkpoint"))
185- markSnapshotDirty(sess)
186- require.True(t, d.scheduleFinalSnapshot(sess))
187- awaitSnapshotIdle(t, sess)
188- attempts, publicationErrors = repository.results()
189- require.Len(t, attempts, 2)
190- require.NoError(t, publicationErrors[1])
191- require.False(t, sess.snapDirty.Load())
192-
193- secondRecord := snapshotLifecycleRecord(t, d, sess.name)
194- require.NotNil(t, secondRecord.Committed)
195- require.Equal(t, firstRecord.Committed.Generation+1, secondRecord.Committed.Generation,
196- "client snatch must not reset or conflict with the repository generation")
197- generation, err := repository.LoadCheckpoint(context.Background(), sess.incarnation, sess.name, *secondRecord.Committed)
198- require.NoError(t, err)
199- require.Equal(t, firstRecord.Committed, generation.ParentCheckpoint,
200- "the post-snatch checkpoint must retain the committed parent")
201-}
202-
203 func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing.T) {144 func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing.T) {
204 keeperPTY, releaseKeeper := newBlockingPTY(t)145 keeperPTY, releaseKeeper := newBlockingPTY(t)
205 firstPTY, releaseFirst := newBlockingPTY(t)146 firstPTY, releaseFirst := newBlockingPTY(t)
M internal/usecase/daemon/status_test.go +2 -2
@@ -31,7 +31,7 @@ func (*coreNilAttachment) snapshotView(viewOptions) sessionView {
3131 return sessionView{}
3232 }
3333 func (*coreNilAttachment) statusSegments(bool) statusSnapshot { return statusSnapshot{} }
34-func (*coreNilAttachment) capturePrimary(*attachedClient, primaryCaptureRequest) (*capturedRenderState, bool) {
34+func (*coreNilAttachment) captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool) {
3535 return nil, false
3636 }
3737 func (*coreNilAttachment) validTargetTabLocked(int) bool { return false }
@@ -1141,7 +1141,7 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(
11411141 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
11421142 capture := func(bars barState) capturedRenderState {
11431143 t.Helper()
1144- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
1144+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{
11451145 bars: bars,
11461146 overlays: capturedOverlayRenderState{},
11471147 preview: picker.Preview{},
@@ -31,7 +31,7 @@ func (*coreNilAttachment) snapshotView(viewOptions) sessionView {
31 return sessionView{}31 return sessionView{}
32 }32 }
33 func (*coreNilAttachment) statusSegments(bool) statusSnapshot { return statusSnapshot{} }33 func (*coreNilAttachment) statusSegments(bool) statusSnapshot { return statusSnapshot{} }
34-func (*coreNilAttachment) capturePrimary(*attachedClient, primaryCaptureRequest) (*capturedRenderState, bool) {34+func (*coreNilAttachment) captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool) {
35 return nil, false35 return nil, false
36 }36 }
37 func (*coreNilAttachment) validTargetTabLocked(int) bool { return false }37 func (*coreNilAttachment) validTargetTabLocked(int) bool { return false }
@@ -1141,7 +1141,7 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(
1141 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)1141 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
1142 capture := func(bars barState) capturedRenderState {1142 capture := func(bars barState) capturedRenderState {
1143 t.Helper()1143 t.Helper()
1144- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{1144+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{
1145 bars: bars,1145 bars: bars,
1146 overlays: capturedOverlayRenderState{},1146 overlays: capturedOverlayRenderState{},
1147 preview: picker.Preview{},1147 preview: picker.Preview{},
M internal/usecase/daemon/structured_diagnostics_test.go +3 -3
@@ -25,7 +25,7 @@ func TestP4StructuredRuntimeDiagnosticsRecordAcceptedSnapshotAndDelta(t *testing
2525 emit := func(reset bool) ports.ScreenUpdate {
2626 t.Helper()
2727 ac.sendMu.Lock()
28- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: reset})
28+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: reset})
2929 require.True(t, ok)
3030 composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
3131 require.True(t, d.emitFrame(proxy, ac, state, composed))
@@ -75,7 +75,7 @@ func TestP4StructuredRuntimeDiagnosticsDoNotRecordFailedSend(t *testing.T) {
7575 require.True(t, applyTestScreenText(proxy, 0, 0, "failed"))
7676
7777 ac.sendMu.Lock()
78- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true})
78+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
7979 require.True(t, ok)
8080 composed := composeFrame(*state, composeCacheInput{})
8181 require.True(t, d.emitFrame(proxy, ac, state, composed))
@@ -122,7 +122,7 @@ func TestP4StructuredRuntimeDiagnosticsObserverRunsOutsideAttachmentAndProxyLock
122122 done := make(chan struct{})
123123 go func() {
124124 ac.sendMu.Lock()
125- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true})
125+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
126126 if !ok {
127127 ac.sendMu.Unlock()
128128 close(done)
@@ -25,7 +25,7 @@ func TestP4StructuredRuntimeDiagnosticsRecordAcceptedSnapshotAndDelta(t *testing
25 emit := func(reset bool) ports.ScreenUpdate {25 emit := func(reset bool) ports.ScreenUpdate {
26 t.Helper()26 t.Helper()
27 ac.sendMu.Lock()27 ac.sendMu.Lock()
28- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: reset})28+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: reset})
29 require.True(t, ok)29 require.True(t, ok)
30 composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)30 composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
31 require.True(t, d.emitFrame(proxy, ac, state, composed))31 require.True(t, d.emitFrame(proxy, ac, state, composed))
@@ -75,7 +75,7 @@ func TestP4StructuredRuntimeDiagnosticsDoNotRecordFailedSend(t *testing.T) {
75 require.True(t, applyTestScreenText(proxy, 0, 0, "failed"))75 require.True(t, applyTestScreenText(proxy, 0, 0, "failed"))
76 76
77 ac.sendMu.Lock()77 ac.sendMu.Lock()
78- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true})78+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
79 require.True(t, ok)79 require.True(t, ok)
80 composed := composeFrame(*state, composeCacheInput{})80 composed := composeFrame(*state, composeCacheInput{})
81 require.True(t, d.emitFrame(proxy, ac, state, composed))81 require.True(t, d.emitFrame(proxy, ac, state, composed))
@@ -122,7 +122,7 @@ func TestP4StructuredRuntimeDiagnosticsObserverRunsOutsideAttachmentAndProxyLock
122 done := make(chan struct{})122 done := make(chan struct{})
123 go func() {123 go func() {
124 ac.sendMu.Lock()124 ac.sendMu.Lock()
125- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true})125+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
126 if !ok {126 if !ok {
127 ac.sendMu.Unlock()127 ac.sendMu.Unlock()
128 close(done)128 close(done)
M internal/usecase/daemon/transactional_resize_test.go +2 -3
@@ -386,10 +386,9 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
386386 name string
387387 run func(*renderCoordinator, *attachedClient, *attachedClient)
388388 }{
389- {"replace", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteReplace(old, next) }},
389+ {"detach and attach", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteDetach(old); rc.attach(next) }},
390390 {"detach", func(rc *renderCoordinator, old, _ *attachedClient) { rc.noteDetach(old) }},
391- {"park", func(rc *renderCoordinator, old, _ *attachedClient) { rc.notePark(old) }},
392- {"resume", func(rc *renderCoordinator, old, next *attachedClient) { rc.notePark(old); rc.attach(next) }},
391+ {"resume", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteDetach(old); rc.attach(next) }},
393392 } {
394393 t.Run(transition.name, func(t *testing.T) {
395394 rc := newRenderCoordinator(renderCoordinatorOptions{})
@@ -386,10 +386,9 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
386 name string386 name string
387 run func(*renderCoordinator, *attachedClient, *attachedClient)387 run func(*renderCoordinator, *attachedClient, *attachedClient)
388 }{388 }{
389- {"replace", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteReplace(old, next) }},389+ {"detach and attach", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteDetach(old); rc.attach(next) }},
390 {"detach", func(rc *renderCoordinator, old, _ *attachedClient) { rc.noteDetach(old) }},390 {"detach", func(rc *renderCoordinator, old, _ *attachedClient) { rc.noteDetach(old) }},
391- {"park", func(rc *renderCoordinator, old, _ *attachedClient) { rc.notePark(old) }},391+ {"resume", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteDetach(old); rc.attach(next) }},
392- {"resume", func(rc *renderCoordinator, old, next *attachedClient) { rc.notePark(old); rc.attach(next) }},
393 } {392 } {
394 t.Run(transition.name, func(t *testing.T) {393 t.Run(transition.name, func(t *testing.T) {
395 rc := newRenderCoordinator(renderCoordinatorOptions{})394 rc := newRenderCoordinator(renderCoordinatorOptions{})

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