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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 447 | 447 | ac.setSession(sess) | |
| 448 | 448 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | |
| 449 | 449 | // 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() | |
| 452 | 454 | sess.mu.Lock() | |
| 453 | 455 | old := sess.client | |
| 454 | 456 | name := sess.name | |
| @@ -457,6 +459,7 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma | |||
| 457 | 459 | sess.mu.Lock() | |
| 458 | 460 | sess.client = ac | |
| 459 | 461 | sess.mu.Unlock() | |
| 462 | + | d.notices.routingMu.Unlock() | |
| 460 | 463 | d.touchMRU(sess) | |
| 461 | 464 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | |
| 462 | 465 | d.applyHostTheme(sess, ac, themeui.Theme{}, true) | |
| @@ -596,9 +599,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||
| 596 | 599 | // Global notices raised while nothing was attached surface on this client. | |
| 597 | 600 | // Drained before the early return below so a session without an active tab | |
| 598 | 601 | // cannot swallow the queue. | |
| 599 | - | for _, n := range d.notices.drainPending() { | |
| 600 | - | d.showToast(ac, n) | |
| 601 | - | } | |
| 602 | + | d.drainPendingForFirstPaint(sess, ac) | |
| 602 | 603 | tb := sess.activeTab() | |
| 603 | 604 | if tb == nil { | |
| 604 | 605 | return | |
| @@ -696,7 +697,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||
| 696 | 697 | if failed != nil && !ac.currentTransportIs(failed) { | |
| 697 | 698 | return // stale connection loop; a newer transport owns this client | |
| 698 | 699 | } | |
| 699 | - | if !sess.detachIfCurrent(ac) { | |
| 700 | + | if !d.detachIfCurrent(sess, ac) { | |
| 700 | 701 | return // already displaced by a newer client; nothing to do | |
| 701 | 702 | } | |
| 702 | 703 | if rc := sess.renderCoordinator(); rc != nil { | |
| @@ -806,7 +807,7 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por | |||
| 806 | 807 | if failed != nil && !ac.currentTransportIs(failed) { | |
| 807 | 808 | return | |
| 808 | 809 | } | |
| 809 | - | if sess.detachIfCurrent(ac) { | |
| 810 | + | if d.detachIfCurrent(sess, ac) { | |
| 810 | 811 | if rc := sess.renderCoordinator(); rc != nil { | |
| 811 | 812 | rc.noteDetach(ac) | |
| 812 | 813 | } | |
| @@ -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 with | 449 | // Daemon attachment callers hold d.mu, serialising this preparation with | ||
| 450 | - | // other publications. Bind coordinator ownership before sess.client becomes | 450 | + | // 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.client | 455 | old := sess.client | ||
| 454 | name := sess.name | 456 | 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 = ac | 460 | 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 tab | 600 | // 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 | return | 605 | 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 client | 698 | 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 do | 701 | 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 | return | 808 | 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") | |||
| 28 | 28 | // global notices awaiting a first attached client. Its mu is leaf-level: no | |
| 29 | 29 | // other lock is ever taken while holding it. | |
| 30 | 30 | 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() | |
| 34 | 44 | } | |
| 35 | 45 | ||
| 36 | 46 | func newNoticeCenter() *noticeCenter { return ¬iceCenter{} } | |
| @@ -169,7 +179,9 @@ func (d *Daemon) reportError(sess *session, err error) { | |||
| 169 | 179 | // notice waits in the pending queue for the next attach. | |
| 170 | 180 | // | |
| 171 | 181 | // 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 | 185 | func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) { | |
| 174 | 186 | n := domain.Notification{ | |
| 175 | 187 | Code: code, | |
| @@ -194,28 +206,64 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No | |||
| 194 | 206 | 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 | 218 | d.mu.Lock() | |
| 219 | + | d.notices.routingMu.Lock() | |
| 198 | 220 | sessions := make([]*session, 0, len(d.sessions)) | |
| 199 | 221 | for _, s := range d.sessions { | |
| 200 | 222 | sessions = append(sessions, s) | |
| 201 | 223 | } | |
| 202 | 224 | d.mu.Unlock() | |
| 225 | + | defer d.notices.routingMu.Unlock() | |
| 203 | 226 | ||
| 227 | + | if d.notices.beforeGlobalDelivery != nil { | |
| 228 | + | d.notices.beforeGlobalDelivery() | |
| 229 | + | } | |
| 204 | 230 | delivered := false | |
| 205 | 231 | for _, s := range sessions { | |
| 206 | 232 | s.mu.Lock() | |
| 207 | 233 | ac := s.client | |
| 208 | 234 | s.mu.Unlock() | |
| 209 | - | if ac != nil { | |
| 210 | - | d.showToast(ac, n) | |
| 211 | - | delivered = true | |
| 235 | + | if ac == nil { | |
| 236 | + | continue | |
| 212 | 237 | } | |
| 238 | + | d.showToast(ac, n) | |
| 239 | + | delivered = true | |
| 213 | 240 | } | |
| 214 | 241 | if !delivered { | |
| 242 | + | if d.notices.beforeQueueGlobal != nil { | |
| 243 | + | d.notices.beforeQueueGlobal() | |
| 244 | + | } | |
| 215 | 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 | 267 | // NotifyGlobal raises a daemon-wide notice from outside the daemon package. | |
| 220 | 268 | func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) { | |
| 221 | 269 | 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: no | 28 | // 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.Mutex | 31 | + | mu sync.Mutex |
| 32 | - | ring []domain.Notification | 32 | + | // routingMu serializes global deliver-or-queue with the first-paint drain. |
| 33 | - | pending []domain.Notification | 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() | |||
| 34 | } | 44 | } | ||
| 35 | 45 | ||||
| 36 | func newNoticeCenter() *noticeCenter { return ¬iceCenter{} } | 46 | func newNoticeCenter() *noticeCenter { return ¬iceCenter{} } | ||
| @@ -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 always | 181 | // 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 | return | 206 | 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 := false | 230 | delivered := false | ||
| 205 | for _, s := range sessions { | 231 | for _, s := range sessions { | ||
| 206 | s.mu.Lock() | 232 | s.mu.Lock() | ||
| 207 | ac := s.client | 233 | 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) { | |||
| 468 | 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 | 577 | func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |
| 472 | 578 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 473 | 579 | 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 | |||
| 468 | 468 | // switchToActiveTargetLocked commits an active target handoff. Caller holds | |
| 469 | 469 | // d.mu and releases it before completing render cleanup or first paint. | |
| 470 | 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 | 475 | if d.sessions[target.Session] != targetSess { | |
| 472 | 476 | return nil, nil, nil, false | |
| 473 | 477 | } | |
| @@ -523,6 +527,10 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 523 | 527 | // the handoff while d.mu is held. Creation failure leaves the source client | |
| 524 | 528 | // and stopped record untouched. | |
| 525 | 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 | 534 | from.mu.Lock() | |
| 527 | 535 | if from.client != ac { | |
| 528 | 536 | from.mu.Unlock() | |
| @@ -468,6 +468,10 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi | |||||
| 468 | // switchToActiveTargetLocked commits an active target handoff. Caller holds | 468 | // 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, false | 476 | 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 client | 527 | // 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 | |||
| 253 | 253 | d.mu.Unlock() | |
| 254 | 254 | 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 | 260 | from.mu.Lock() | |
| 257 | 261 | cwd := from.cwd | |
| 258 | 262 | term := from.terminal | |
| 259 | 263 | env := copyEnvironment(from.env) | |
| 260 | 264 | if from.client != ac { | |
| 261 | 265 | from.mu.Unlock() | |
| 266 | + | d.notices.routingMu.Unlock() | |
| 262 | 267 | d.mu.Unlock() | |
| 263 | 268 | return errors.New("client detached") | |
| 264 | 269 | } | |
| @@ -266,12 +271,14 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 266 | 271 | ||
| 267 | 272 | newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env) | |
| 268 | 273 | if err != nil { | |
| 274 | + | d.notices.routingMu.Unlock() | |
| 269 | 275 | d.mu.Unlock() | |
| 270 | 276 | return err | |
| 271 | 277 | } | |
| 272 | 278 | from.mu.Lock() | |
| 273 | 279 | if from.client != ac { | |
| 274 | 280 | from.mu.Unlock() | |
| 281 | + | d.notices.routingMu.Unlock() | |
| 275 | 282 | d.mu.Unlock() | |
| 276 | 283 | _ = d.killSession(newSess, ports.ReasonSessionKilled, true) | |
| 277 | 284 | return errors.New("client detached") | |
| @@ -291,6 +298,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 291 | 298 | d.touchMRU(newSess) | |
| 292 | 299 | ac.recordPreviousSession(from) | |
| 293 | 300 | d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable) | |
| 301 | + | d.notices.routingMu.Unlock() | |
| 294 | 302 | d.mu.Unlock() | |
| 295 | 303 | finishRenderLifecycleCleanups(cleanups) | |
| 296 | 304 | d.firstPaint(newSess, ac, sz) | |
| @@ -464,6 +472,15 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool { | |||
| 464 | 472 | 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 | 484 | func (s *session) activeTab() *tab { | |
| 468 | 485 | s.mu.Lock() | |
| 469 | 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 | 816 | sess.mu.Lock() | |
| 797 | 817 | ac := sess.client | |
| 798 | 818 | sess.client = nil | |
| 799 | 819 | sess.mu.Unlock() | |
| 820 | + | d.notices.routingMu.Unlock() | |
| 800 | 821 | if rc := sess.renderCoordinator(); rc != nil { | |
| 801 | 822 | // Terminal teardown has two phases: this session owner first prevents any | |
| 802 | 823 | // 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 errSessionNameInUse | 254 | 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.cwd | 261 | cwd := from.cwd | ||
| 258 | term := from.terminal | 262 | 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 err | 276 | 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 false | 472 | 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.client | 817 | ac := sess.client | ||
| 798 | sess.client = nil | 818 | 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 any | 822 | // Terminal teardown has two phases: this session owner first prevents any | ||
| 802 | // new worker registration and detaches all tokens, then stops and waits | 823 | // new worker registration and detaches all tokens, then stops and waits | ||