detached b0ecd73

fix(daemon): make global notice routing attachment-safe

commit
b0ecd73275a9f99bf262bb6bc5f157d88985b783
author
brice · · 2026-07-21 10:24 UTC
committer
brice · · 2026-07-21 10:26 UTC
parent
4fdfae1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +198 -14
M internal/usecase/daemon/client.go +8 -7
@@ -447,8 +447,10 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
447447 ac.setSession(sess)
448448 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
449449 // Daemon attachment callers hold d.mu, serialising this preparation with
450- // other publications. Bind coordinator ownership before sess.client becomes
451- // visible: an old deadline can then never target this new output chain.
450+ // other publications. routingMu makes the whole replacement atomic with
451+ // global notice selection: a selected old client cannot become stale before
452+ // its toast is published, and a new client is never missed after publication.
453+ d.notices.routingMu.Lock()
452454 sess.mu.Lock()
453455 old := sess.client
454456 name := sess.name
@@ -457,6 +459,7 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
457459 sess.mu.Lock()
458460 sess.client = ac
459461 sess.mu.Unlock()
462+ d.notices.routingMu.Unlock()
460463 d.touchMRU(sess)
461464 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
462465 d.applyHostTheme(sess, ac, themeui.Theme{}, true)
@@ -596,9 +599,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
596599 // Global notices raised while nothing was attached surface on this client.
597600 // Drained before the early return below so a session without an active tab
598601 // cannot swallow the queue.
599- for _, n := range d.notices.drainPending() {
600- d.showToast(ac, n)
601- }
602+ d.drainPendingForFirstPaint(sess, ac)
602603 tb := sess.activeTab()
603604 if tb == nil {
604605 return
@@ -696,7 +697,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
696697 if failed != nil && !ac.currentTransportIs(failed) {
697698 return // stale connection loop; a newer transport owns this client
698699 }
699- if !sess.detachIfCurrent(ac) {
700+ if !d.detachIfCurrent(sess, ac) {
700701 return // already displaced by a newer client; nothing to do
701702 }
702703 if rc := sess.renderCoordinator(); rc != nil {
@@ -806,7 +807,7 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
806807 if failed != nil && !ac.currentTransportIs(failed) {
807808 return
808809 }
809- if sess.detachIfCurrent(ac) {
810+ if d.detachIfCurrent(sess, ac) {
810811 if rc := sess.renderCoordinator(); rc != nil {
811812 rc.noteDetach(ac)
812813 }
@@ -447,8 +447,10 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
447 ac.setSession(sess)447 ac.setSession(sess)
448 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)448 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
449 // Daemon attachment callers hold d.mu, serialising this preparation with449 // Daemon attachment callers hold d.mu, serialising this preparation with
450- // other publications. Bind coordinator ownership before sess.client becomes450+ // other publications. routingMu makes the whole replacement atomic with
451- // visible: an old deadline can then never target this new output chain.451+ // global notice selection: a selected old client cannot become stale before
452+ // its toast is published, and a new client is never missed after publication.
453+ d.notices.routingMu.Lock()
452 sess.mu.Lock()454 sess.mu.Lock()
453 old := sess.client455 old := sess.client
454 name := sess.name456 name := sess.name
@@ -457,6 +459,7 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
457 sess.mu.Lock()459 sess.mu.Lock()
458 sess.client = ac460 sess.client = ac
459 sess.mu.Unlock()461 sess.mu.Unlock()
462+ d.notices.routingMu.Unlock()
460 d.touchMRU(sess)463 d.touchMRU(sess)
461 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)464 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
462 d.applyHostTheme(sess, ac, themeui.Theme{}, true)465 d.applyHostTheme(sess, ac, themeui.Theme{}, true)
@@ -596,9 +599,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
596 // Global notices raised while nothing was attached surface on this client.599 // Global notices raised while nothing was attached surface on this client.
597 // Drained before the early return below so a session without an active tab600 // Drained before the early return below so a session without an active tab
598 // cannot swallow the queue.601 // cannot swallow the queue.
599- for _, n := range d.notices.drainPending() {602+ d.drainPendingForFirstPaint(sess, ac)
600- d.showToast(ac, n)
601- }
602 tb := sess.activeTab()603 tb := sess.activeTab()
603 if tb == nil {604 if tb == nil {
604 return605 return
@@ -696,7 +697,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
696 if failed != nil && !ac.currentTransportIs(failed) {697 if failed != nil && !ac.currentTransportIs(failed) {
697 return // stale connection loop; a newer transport owns this client698 return // stale connection loop; a newer transport owns this client
698 }699 }
699- if !sess.detachIfCurrent(ac) {700+ if !d.detachIfCurrent(sess, ac) {
700 return // already displaced by a newer client; nothing to do701 return // already displaced by a newer client; nothing to do
701 }702 }
702 if rc := sess.renderCoordinator(); rc != nil {703 if rc := sess.renderCoordinator(); rc != nil {
@@ -806,7 +807,7 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
806 if failed != nil && !ac.currentTransportIs(failed) {807 if failed != nil && !ac.currentTransportIs(failed) {
807 return808 return
808 }809 }
809- if sess.detachIfCurrent(ac) {810+ if d.detachIfCurrent(sess, ac) {
810 if rc := sess.renderCoordinator(); rc != nil {811 if rc := sess.renderCoordinator(); rc != nil {
811 rc.noteDetach(ac)812 rc.noteDetach(ac)
812 }813 }
M internal/usecase/daemon/notify.go +55 -7
@@ -28,9 +28,19 @@ var errNoNeighbor = errors.New("no pane in that direction")
2828 // global notices awaiting a first attached client. Its mu is leaf-level: no
2929 // other lock is ever taken while holding it.
3030 type noticeCenter struct {
31- mu sync.Mutex
32- ring []domain.Notification
33- pending []domain.Notification
31+ mu sync.Mutex
32+ // routingMu serializes global deliver-or-queue with the first-paint drain.
33+ // Attachment removal also takes it, so a client selected for a global toast
34+ // remains current until that toast is published.
35+ routingMu sync.Mutex
36+ ring []domain.Notification
37+ pending []domain.Notification
38+ // beforeQueueGlobal is a deterministic test seam after routing has observed
39+ // no attachments and before it queues the notice.
40+ beforeQueueGlobal func()
41+ // beforeGlobalDelivery is a deterministic test seam after targets have been
42+ // selected and before their toast stacks are touched.
43+ beforeGlobalDelivery func()
3444 }
3545
3646 func newNoticeCenter() *noticeCenter { return &noticeCenter{} }
@@ -169,7 +179,9 @@ func (d *Daemon) reportError(sess *session, err error) {
169179 // notice waits in the pending queue for the next attach.
170180 //
171181 // Locking: sess.mu and d.mu are only held to snapshot; showToast is always
172-// called with no daemon, session, or pane lock held.
182+// called with no daemon, session, or pane lock held. Global routing holds the
183+// notice center's routingMu across selection and publication, which serializes
184+// it with attachment detach and pending-queue drains.
173185 func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
174186 n := domain.Notification{
175187 Code: code,
@@ -194,28 +206,64 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
194206 return
195207 }
196208
209+ d.deliverGlobal(n)
210+}
211+
212+// deliverGlobal atomically selects all current attachments and either publishes
213+// to all of them or queues the notice. routingMu is deliberately held through
214+// showToast, but never with d.mu or sess.mu held: detach cannot turn a selected
215+// target stale between selection and publication, and firstPaint cannot drain
216+// before an unattached route has queued its notice.
217+func (d *Daemon) deliverGlobal(n domain.Notification) {
197218 d.mu.Lock()
219+ d.notices.routingMu.Lock()
198220 sessions := make([]*session, 0, len(d.sessions))
199221 for _, s := range d.sessions {
200222 sessions = append(sessions, s)
201223 }
202224 d.mu.Unlock()
225+ defer d.notices.routingMu.Unlock()
203226
227+ if d.notices.beforeGlobalDelivery != nil {
228+ d.notices.beforeGlobalDelivery()
229+ }
204230 delivered := false
205231 for _, s := range sessions {
206232 s.mu.Lock()
207233 ac := s.client
208234 s.mu.Unlock()
209- if ac != nil {
210- d.showToast(ac, n)
211- delivered = true
235+ if ac == nil {
236+ continue
212237 }
238+ d.showToast(ac, n)
239+ delivered = true
213240 }
214241 if !delivered {
242+ if d.notices.beforeQueueGlobal != nil {
243+ d.notices.beforeQueueGlobal()
244+ }
215245 d.notices.queueGlobal(n)
216246 }
217247 }
218248
249+// drainPendingForFirstPaint publishes pending globals only while ac remains the
250+// session's current attachment. It shares routingMu with deliverGlobal, so an
251+// unattached global route cannot queue after this drain has already happened.
252+func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
253+ d.notices.routingMu.Lock()
254+ defer d.notices.routingMu.Unlock()
255+
256+ sess.mu.Lock()
257+ current := sess.client == ac
258+ sess.mu.Unlock()
259+ if !current {
260+ return
261+ }
262+ for _, n := range d.notices.drainPending() {
263+ d.showToast(ac, n)
264+ }
265+}
266+
219267 // NotifyGlobal raises a daemon-wide notice from outside the daemon package.
220268 func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
221269 d.notify(nil, sev, code, msg, cause)
@@ -28,9 +28,19 @@ var errNoNeighbor = errors.New("no pane in that direction")
28 // global notices awaiting a first attached client. Its mu is leaf-level: no28 // global notices awaiting a first attached client. Its mu is leaf-level: no
29 // other lock is ever taken while holding it.29 // other lock is ever taken while holding it.
30 type noticeCenter struct {30 type noticeCenter struct {
31- mu sync.Mutex31+ mu sync.Mutex
32- ring []domain.Notification32+ // routingMu serializes global deliver-or-queue with the first-paint drain.
33- pending []domain.Notification33+ // Attachment removal also takes it, so a client selected for a global toast
34+ // remains current until that toast is published.
35+ routingMu sync.Mutex
36+ ring []domain.Notification
37+ pending []domain.Notification
38+ // beforeQueueGlobal is a deterministic test seam after routing has observed
39+ // no attachments and before it queues the notice.
40+ beforeQueueGlobal func()
41+ // beforeGlobalDelivery is a deterministic test seam after targets have been
42+ // selected and before their toast stacks are touched.
43+ beforeGlobalDelivery func()
34 }44 }
35 45
36 func newNoticeCenter() *noticeCenter { return &noticeCenter{} }46 func newNoticeCenter() *noticeCenter { return &noticeCenter{} }
@@ -169,7 +179,9 @@ func (d *Daemon) reportError(sess *session, err error) {
169 // notice waits in the pending queue for the next attach.179 // notice waits in the pending queue for the next attach.
170 //180 //
171 // Locking: sess.mu and d.mu are only held to snapshot; showToast is always181 // Locking: sess.mu and d.mu are only held to snapshot; showToast is always
172-// called with no daemon, session, or pane lock held.182+// called with no daemon, session, or pane lock held. Global routing holds the
183+// notice center's routingMu across selection and publication, which serializes
184+// it with attachment detach and pending-queue drains.
173 func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {185 func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
174 n := domain.Notification{186 n := domain.Notification{
175 Code: code,187 Code: code,
@@ -194,28 +206,64 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
194 return206 return
195 }207 }
196 208
209+ d.deliverGlobal(n)
210+}
211+
212+// deliverGlobal atomically selects all current attachments and either publishes
213+// to all of them or queues the notice. routingMu is deliberately held through
214+// showToast, but never with d.mu or sess.mu held: detach cannot turn a selected
215+// target stale between selection and publication, and firstPaint cannot drain
216+// before an unattached route has queued its notice.
217+func (d *Daemon) deliverGlobal(n domain.Notification) {
197 d.mu.Lock()218 d.mu.Lock()
219+ d.notices.routingMu.Lock()
198 sessions := make([]*session, 0, len(d.sessions))220 sessions := make([]*session, 0, len(d.sessions))
199 for _, s := range d.sessions {221 for _, s := range d.sessions {
200 sessions = append(sessions, s)222 sessions = append(sessions, s)
201 }223 }
202 d.mu.Unlock()224 d.mu.Unlock()
225+ defer d.notices.routingMu.Unlock()
203 226
227+ if d.notices.beforeGlobalDelivery != nil {
228+ d.notices.beforeGlobalDelivery()
229+ }
204 delivered := false230 delivered := false
205 for _, s := range sessions {231 for _, s := range sessions {
206 s.mu.Lock()232 s.mu.Lock()
207 ac := s.client233 ac := s.client
208 s.mu.Unlock()234 s.mu.Unlock()
209- if ac != nil {235+ if ac == nil {
210- d.showToast(ac, n)236+ continue
211- delivered = true
212 }237 }
238+ d.showToast(ac, n)
239+ delivered = true
213 }240 }
214 if !delivered {241 if !delivered {
242+ if d.notices.beforeQueueGlobal != nil {
243+ d.notices.beforeQueueGlobal()
244+ }
215 d.notices.queueGlobal(n)245 d.notices.queueGlobal(n)
216 }246 }
217 }247 }
218 248
249+// drainPendingForFirstPaint publishes pending globals only while ac remains the
250+// session's current attachment. It shares routingMu with deliverGlobal, so an
251+// unattached global route cannot queue after this drain has already happened.
252+func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
253+ d.notices.routingMu.Lock()
254+ defer d.notices.routingMu.Unlock()
255+
256+ sess.mu.Lock()
257+ current := sess.client == ac
258+ sess.mu.Unlock()
259+ if !current {
260+ return
261+ }
262+ for _, n := range d.notices.drainPending() {
263+ d.showToast(ac, n)
264+ }
265+}
266+
219 // NotifyGlobal raises a daemon-wide notice from outside the daemon package.267 // NotifyGlobal raises a daemon-wide notice from outside the daemon package.
220 func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {268 func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
221 d.notify(nil, sev, code, msg, cause)269 d.notify(nil, sev, code, msg, cause)
M internal/usecase/daemon/notify_test.go +106 -0
@@ -468,6 +468,112 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
468468 require.Empty(t, d.notices.drainPending(), "delivered globals must not also be queued")
469469 }
470470
471+func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) {
472+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
473+ sess.mu.Lock()
474+ sess.client = nil
475+ sess.mu.Unlock()
476+
477+ routeObserved := make(chan struct{})
478+ releaseRoute := make(chan struct{})
479+ d.notices.beforeQueueGlobal = func() {
480+ close(routeObserved)
481+ <-releaseRoute
482+ }
483+ globalDone := make(chan struct{})
484+ go func() {
485+ d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil)
486+ close(globalDone)
487+ }()
488+ <-routeObserved // global routing has observed the detached session
489+
490+ // Publish the attachment and start firstPaint while global routing still
491+ // owns its gate. firstPaint must wait for the queue, then drain it.
492+ sess.mu.Lock()
493+ sess.client = ac
494+ sess.mu.Unlock()
495+ paintDone := make(chan struct{})
496+ go func() {
497+ d.firstPaint(sess, ac, domain.Size{})
498+ close(paintDone)
499+ }()
500+
501+ close(releaseRoute)
502+ <-globalDone
503+ <-paintDone
504+ toasts, _ := visibleToasts(ac)
505+ require.Len(t, toasts, 1)
506+ require.Equal(t, domain.NoticeSnapshotWrite, toasts[0].Code)
507+ require.Empty(t, d.notices.drainPending())
508+}
509+
510+func TestNotifyGlobalSerializesDetachWithDelivery(t *testing.T) {
511+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
512+
513+ routeObserved := make(chan struct{})
514+ releaseRoute := make(chan struct{})
515+ d.notices.beforeGlobalDelivery = func() {
516+ close(routeObserved)
517+ <-releaseRoute
518+ }
519+ globalDone := make(chan struct{})
520+ go func() {
521+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil)
522+ close(globalDone)
523+ }()
524+ <-routeObserved // ac is selected and routingMu is held
525+
526+ detached := make(chan bool, 1)
527+ go func() { detached <- d.detachIfCurrent(sess, ac) }()
528+ close(releaseRoute)
529+ <-globalDone
530+ require.True(t, <-detached)
531+
532+ // The selected attachment received the toast before it could become stale.
533+ toasts, _ := visibleToasts(ac)
534+ require.Len(t, toasts, 1)
535+ sess.mu.Lock()
536+ require.Nil(t, sess.client)
537+ sess.mu.Unlock()
538+}
539+
540+func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) {
541+ d, sess, old, _ := newNoticeFixture(t, newNoticeClock())
542+
543+ routeObserved := make(chan struct{})
544+ releaseRoute := make(chan struct{})
545+ d.notices.beforeGlobalDelivery = func() {
546+ close(routeObserved)
547+ <-releaseRoute
548+ }
549+ globalDone := make(chan struct{})
550+ go func() {
551+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil)
552+ close(globalDone)
553+ }()
554+ <-routeObserved // old is selected and routingMu is held
555+
556+ tr, _ := newCapturingTransport(t)
557+ replaced := make(chan *attachedClient, 1)
558+ go func() {
559+ ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
560+ replaced <- ac
561+ }()
562+ close(releaseRoute)
563+ <-globalDone
564+ current := <-replaced
565+
566+ // Replacement cannot make old a stale delivery target: it publishes only
567+ // after the selected attachment has received the global toast.
568+ oldToasts, _ := visibleToasts(old)
569+ require.Len(t, oldToasts, 1)
570+ newToasts, _ := visibleToasts(current)
571+ require.Empty(t, newToasts)
572+ sess.mu.Lock()
573+ require.Same(t, current, sess.client)
574+ sess.mu.Unlock()
575+}
576+
471577 func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
472578 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
473579 sess.mu.Lock()
@@ -468,6 +468,112 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
468 require.Empty(t, d.notices.drainPending(), "delivered globals must not also be queued")468 require.Empty(t, d.notices.drainPending(), "delivered globals must not also be queued")
469 }469 }
470 470
471+func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) {
472+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
473+ sess.mu.Lock()
474+ sess.client = nil
475+ sess.mu.Unlock()
476+
477+ routeObserved := make(chan struct{})
478+ releaseRoute := make(chan struct{})
479+ d.notices.beforeQueueGlobal = func() {
480+ close(routeObserved)
481+ <-releaseRoute
482+ }
483+ globalDone := make(chan struct{})
484+ go func() {
485+ d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil)
486+ close(globalDone)
487+ }()
488+ <-routeObserved // global routing has observed the detached session
489+
490+ // Publish the attachment and start firstPaint while global routing still
491+ // owns its gate. firstPaint must wait for the queue, then drain it.
492+ sess.mu.Lock()
493+ sess.client = ac
494+ sess.mu.Unlock()
495+ paintDone := make(chan struct{})
496+ go func() {
497+ d.firstPaint(sess, ac, domain.Size{})
498+ close(paintDone)
499+ }()
500+
501+ close(releaseRoute)
502+ <-globalDone
503+ <-paintDone
504+ toasts, _ := visibleToasts(ac)
505+ require.Len(t, toasts, 1)
506+ require.Equal(t, domain.NoticeSnapshotWrite, toasts[0].Code)
507+ require.Empty(t, d.notices.drainPending())
508+}
509+
510+func TestNotifyGlobalSerializesDetachWithDelivery(t *testing.T) {
511+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
512+
513+ routeObserved := make(chan struct{})
514+ releaseRoute := make(chan struct{})
515+ d.notices.beforeGlobalDelivery = func() {
516+ close(routeObserved)
517+ <-releaseRoute
518+ }
519+ globalDone := make(chan struct{})
520+ go func() {
521+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil)
522+ close(globalDone)
523+ }()
524+ <-routeObserved // ac is selected and routingMu is held
525+
526+ detached := make(chan bool, 1)
527+ go func() { detached <- d.detachIfCurrent(sess, ac) }()
528+ close(releaseRoute)
529+ <-globalDone
530+ require.True(t, <-detached)
531+
532+ // The selected attachment received the toast before it could become stale.
533+ toasts, _ := visibleToasts(ac)
534+ require.Len(t, toasts, 1)
535+ sess.mu.Lock()
536+ require.Nil(t, sess.client)
537+ sess.mu.Unlock()
538+}
539+
540+func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) {
541+ d, sess, old, _ := newNoticeFixture(t, newNoticeClock())
542+
543+ routeObserved := make(chan struct{})
544+ releaseRoute := make(chan struct{})
545+ d.notices.beforeGlobalDelivery = func() {
546+ close(routeObserved)
547+ <-releaseRoute
548+ }
549+ globalDone := make(chan struct{})
550+ go func() {
551+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil)
552+ close(globalDone)
553+ }()
554+ <-routeObserved // old is selected and routingMu is held
555+
556+ tr, _ := newCapturingTransport(t)
557+ replaced := make(chan *attachedClient, 1)
558+ go func() {
559+ ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
560+ replaced <- ac
561+ }()
562+ close(releaseRoute)
563+ <-globalDone
564+ current := <-replaced
565+
566+ // Replacement cannot make old a stale delivery target: it publishes only
567+ // after the selected attachment has received the global toast.
568+ oldToasts, _ := visibleToasts(old)
569+ require.Len(t, oldToasts, 1)
570+ newToasts, _ := visibleToasts(current)
571+ require.Empty(t, newToasts)
572+ sess.mu.Lock()
573+ require.Same(t, current, sess.client)
574+ sess.mu.Unlock()
575+}
576+
471 func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {577 func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
472 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())578 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
473 sess.mu.Lock()579 sess.mu.Lock()
M internal/usecase/daemon/picker.go +8 -0
@@ -468,6 +468,10 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
468468 // switchToActiveTargetLocked commits an active target handoff. Caller holds
469469 // d.mu and releases it before completing render cleanup or first paint.
470470 func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) {
471+ // The caller already holds d.mu. Keep handoff ownership atomic with global
472+ // routing so it cannot select an attachment midway between sessions.
473+ d.notices.routingMu.Lock()
474+ defer d.notices.routingMu.Unlock()
471475 if d.sessions[target.Session] != targetSess {
472476 return nil, nil, nil, false
473477 }
@@ -523,6 +527,10 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
523527 // the handoff while d.mu is held. Creation failure leaves the source client
524528 // and stopped record untouched.
525529 func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) {
530+ // The caller already holds d.mu. Keep handoff ownership atomic with global
531+ // routing so it cannot select an attachment midway between sessions.
532+ d.notices.routingMu.Lock()
533+ defer d.notices.routingMu.Unlock()
526534 from.mu.Lock()
527535 if from.client != ac {
528536 from.mu.Unlock()
@@ -468,6 +468,10 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
468 // switchToActiveTargetLocked commits an active target handoff. Caller holds468 // switchToActiveTargetLocked commits an active target handoff. Caller holds
469 // d.mu and releases it before completing render cleanup or first paint.469 // d.mu and releases it before completing render cleanup or first paint.
470 func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) {470 func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) {
471+ // The caller already holds d.mu. Keep handoff ownership atomic with global
472+ // routing so it cannot select an attachment midway between sessions.
473+ d.notices.routingMu.Lock()
474+ defer d.notices.routingMu.Unlock()
471 if d.sessions[target.Session] != targetSess {475 if d.sessions[target.Session] != targetSess {
472 return nil, nil, nil, false476 return nil, nil, nil, false
473 }477 }
@@ -523,6 +527,10 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
523 // the handoff while d.mu is held. Creation failure leaves the source client527 // the handoff while d.mu is held. Creation failure leaves the source client
524 // and stopped record untouched.528 // and stopped record untouched.
525 func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) {529 func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) {
530+ // The caller already holds d.mu. Keep handoff ownership atomic with global
531+ // routing so it cannot select an attachment midway between sessions.
532+ d.notices.routingMu.Lock()
533+ defer d.notices.routingMu.Unlock()
526 from.mu.Lock()534 from.mu.Lock()
527 if from.client != ac {535 if from.client != ac {
528 from.mu.Unlock()536 from.mu.Unlock()
M internal/usecase/daemon/session.go +21 -0
@@ -253,12 +253,17 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
253253 d.mu.Unlock()
254254 return errSessionNameInUse
255255 }
256+ // The caller owns d.mu. Make the entire ownership handoff atomic with
257+ // global routing so it cannot select a client between source removal and
258+ // destination publication.
259+ d.notices.routingMu.Lock()
256260 from.mu.Lock()
257261 cwd := from.cwd
258262 term := from.terminal
259263 env := copyEnvironment(from.env)
260264 if from.client != ac {
261265 from.mu.Unlock()
266+ d.notices.routingMu.Unlock()
262267 d.mu.Unlock()
263268 return errors.New("client detached")
264269 }
@@ -266,12 +271,14 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
266271
267272 newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env)
268273 if err != nil {
274+ d.notices.routingMu.Unlock()
269275 d.mu.Unlock()
270276 return err
271277 }
272278 from.mu.Lock()
273279 if from.client != ac {
274280 from.mu.Unlock()
281+ d.notices.routingMu.Unlock()
275282 d.mu.Unlock()
276283 _ = d.killSession(newSess, ports.ReasonSessionKilled, true)
277284 return errors.New("client detached")
@@ -291,6 +298,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
291298 d.touchMRU(newSess)
292299 ac.recordPreviousSession(from)
293300 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
301+ d.notices.routingMu.Unlock()
294302 d.mu.Unlock()
295303 finishRenderLifecycleCleanups(cleanups)
296304 d.firstPaint(newSess, ac, sz)
@@ -464,6 +472,15 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool {
464472 return false
465473 }
466474
475+// detachIfCurrent serializes client removal with global notice routing. The
476+// routing lock is released before the caller performs teardown, so it covers
477+// only attachment ownership and is never held across transport work.
478+func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
479+ d.notices.routingMu.Lock()
480+ defer d.notices.routingMu.Unlock()
481+ return sess.detachIfCurrent(ac)
482+}
483+
467484 func (s *session) activeTab() *tab {
468485 s.mu.Lock()
469486 defer s.mu.Unlock()
@@ -793,10 +810,14 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
793810 }
794811 }
795812
813+ // A global route that selected this attachment must publish before terminal
814+ // teardown makes it stale.
815+ d.notices.routingMu.Lock()
796816 sess.mu.Lock()
797817 ac := sess.client
798818 sess.client = nil
799819 sess.mu.Unlock()
820+ d.notices.routingMu.Unlock()
800821 if rc := sess.renderCoordinator(); rc != nil {
801822 // Terminal teardown has two phases: this session owner first prevents any
802823 // new worker registration and detaches all tokens, then stops and waits
@@ -253,12 +253,17 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
253 d.mu.Unlock()253 d.mu.Unlock()
254 return errSessionNameInUse254 return errSessionNameInUse
255 }255 }
256+ // The caller owns d.mu. Make the entire ownership handoff atomic with
257+ // global routing so it cannot select a client between source removal and
258+ // destination publication.
259+ d.notices.routingMu.Lock()
256 from.mu.Lock()260 from.mu.Lock()
257 cwd := from.cwd261 cwd := from.cwd
258 term := from.terminal262 term := from.terminal
259 env := copyEnvironment(from.env)263 env := copyEnvironment(from.env)
260 if from.client != ac {264 if from.client != ac {
261 from.mu.Unlock()265 from.mu.Unlock()
266+ d.notices.routingMu.Unlock()
262 d.mu.Unlock()267 d.mu.Unlock()
263 return errors.New("client detached")268 return errors.New("client detached")
264 }269 }
@@ -266,12 +271,14 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
266 271
267 newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env)272 newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env)
268 if err != nil {273 if err != nil {
274+ d.notices.routingMu.Unlock()
269 d.mu.Unlock()275 d.mu.Unlock()
270 return err276 return err
271 }277 }
272 from.mu.Lock()278 from.mu.Lock()
273 if from.client != ac {279 if from.client != ac {
274 from.mu.Unlock()280 from.mu.Unlock()
281+ d.notices.routingMu.Unlock()
275 d.mu.Unlock()282 d.mu.Unlock()
276 _ = d.killSession(newSess, ports.ReasonSessionKilled, true)283 _ = d.killSession(newSess, ports.ReasonSessionKilled, true)
277 return errors.New("client detached")284 return errors.New("client detached")
@@ -291,6 +298,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
291 d.touchMRU(newSess)298 d.touchMRU(newSess)
292 ac.recordPreviousSession(from)299 ac.recordPreviousSession(from)
293 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)300 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
301+ d.notices.routingMu.Unlock()
294 d.mu.Unlock()302 d.mu.Unlock()
295 finishRenderLifecycleCleanups(cleanups)303 finishRenderLifecycleCleanups(cleanups)
296 d.firstPaint(newSess, ac, sz)304 d.firstPaint(newSess, ac, sz)
@@ -464,6 +472,15 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool {
464 return false472 return false
465 }473 }
466 474
475+// detachIfCurrent serializes client removal with global notice routing. The
476+// routing lock is released before the caller performs teardown, so it covers
477+// only attachment ownership and is never held across transport work.
478+func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
479+ d.notices.routingMu.Lock()
480+ defer d.notices.routingMu.Unlock()
481+ return sess.detachIfCurrent(ac)
482+}
483+
467 func (s *session) activeTab() *tab {484 func (s *session) activeTab() *tab {
468 s.mu.Lock()485 s.mu.Lock()
469 defer s.mu.Unlock()486 defer s.mu.Unlock()
@@ -793,10 +810,14 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
793 }810 }
794 }811 }
795 812
813+ // A global route that selected this attachment must publish before terminal
814+ // teardown makes it stale.
815+ d.notices.routingMu.Lock()
796 sess.mu.Lock()816 sess.mu.Lock()
797 ac := sess.client817 ac := sess.client
798 sess.client = nil818 sess.client = nil
799 sess.mu.Unlock()819 sess.mu.Unlock()
820+ d.notices.routingMu.Unlock()
800 if rc := sess.renderCoordinator(); rc != nil {821 if rc := sess.renderCoordinator(); rc != nil {
801 // Terminal teardown has two phases: this session owner first prevents any822 // Terminal teardown has two phases: this session owner first prevents any
802 // new worker registration and detaches all tokens, then stops and waits823 // new worker registration and detaches all tokens, then stops and waits

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