detached 4165239

fix(daemon): serialize toast publication with detach

commit
4165239d78e9538070d8ae078bc59ccd658b1c43
author
brice · · 2026-07-23 21:38 UTC
parent
6ea049e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +103 -26
M internal/usecase/daemon/notify.go +46 -26
@@ -41,6 +41,9 @@ type noticeCenter struct {
4141 // beforeGlobalDelivery is a deterministic test seam after targets have been
4242 // selected and before their toast stacks are touched.
4343 beforeGlobalDelivery func()
44+ // beforeSessionDelivery is a deterministic test seam after a session client
45+ // has been selected and before its toast stack is touched.
46+ beforeSessionDelivery func()
4447 // beforeClientNoticeMutation is a deterministic test seam after a client
4548 // notice's ownership validation and before its toast state is changed.
4649 beforeClientNoticeMutation func()
@@ -254,11 +257,16 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
254257 d.notices.routingMu.Unlock()
255258 return
256259 }
257- // Live session-scoped notices do not use the pending queue, so release
258- // routing before painting. Painting can itself report an error, which
259- // may recursively route a notice.
260+ if d.notices.beforeSessionDelivery != nil {
261+ d.notices.beforeSessionDelivery()
262+ }
263+ // Publish the toast while detach is excluded, then release routing before
264+ // repainting. Rendering can report another notice and re-enter routing.
265+ published := d.publishToast(ac, n)
260266 d.notices.routingMu.Unlock()
261- d.showToast(ac, n)
267+ if published {
268+ d.repaintForNotice(ac)
269+ }
262270 return
263271 }
264272
@@ -266,10 +274,9 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
266274 }
267275
268276 // deliverGlobal atomically selects all current attachments and either publishes
269-// to all of them or queues the notice. routingMu is deliberately held through
270-// showToast, but never with d.mu or sess.mu held: detach cannot turn a selected
271-// target stale between selection and publication, and firstPaint cannot drain
272-// before an unattached route has queued its notice.
277+// to all of them or queues the notice. routingMu is held through toast-state
278+// publication, but never through repaint: detach cannot turn a selected target
279+// stale before publication, while rendering remains free to re-enter routing.
273280 func (d *Daemon) deliverGlobal(n domain.Notification) {
274281 d.mu.Lock()
275282 d.notices.routingMu.Lock()
@@ -278,28 +285,31 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
278285 sessions = append(sessions, s)
279286 }
280287 d.mu.Unlock()
281- defer d.notices.routingMu.Unlock()
282288
283289 if d.notices.beforeGlobalDelivery != nil {
284290 d.notices.beforeGlobalDelivery()
285291 }
286- delivered := false
292+ targets := make([]*attachedClient, 0, len(sessions))
287293 for _, s := range sessions {
288294 s.mu.Lock()
289295 ac := s.client
290296 s.mu.Unlock()
291- if ac == nil {
297+ if ac == nil || !d.publishToast(ac, n) {
292298 continue
293299 }
294- d.showToast(ac, n)
295- delivered = true
300+ targets = append(targets, ac)
296301 }
297- if !delivered {
302+ if len(targets) == 0 {
298303 if d.notices.beforeQueueGlobal != nil {
299304 d.notices.beforeQueueGlobal()
300305 }
301306 d.notices.queueGlobal(n)
302307 }
308+ d.notices.routingMu.Unlock()
309+
310+ for _, ac := range targets {
311+ d.repaintForNotice(ac)
312+ }
303313 }
304314
305315 // drainPendingForFirstPaint publishes pending globals and notices scoped to
@@ -308,18 +318,19 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
308318 // drain has already happened.
309319 func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
310320 d.notices.routingMu.Lock()
311- defer d.notices.routingMu.Unlock()
312321
313322 sess.mu.Lock()
314323 current := sess.client == ac
315324 sess.mu.Unlock()
316325 if !current {
326+ d.notices.routingMu.Unlock()
317327 return
318328 }
329+ published := false
319330 var keep []domain.Notification
320331 for _, n := range d.notices.drainPending() {
321332 if n.SessionID == "" || n.SessionID == sess.id {
322- d.showToast(ac, n)
333+ published = d.publishToast(ac, n) || published
323334 continue
324335 }
325336 keep = append(keep, n)
@@ -327,6 +338,10 @@ func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
327338 for _, n := range keep {
328339 d.notices.queueGlobal(n)
329340 }
341+ d.notices.routingMu.Unlock()
342+ if published {
343+ d.repaintForNotice(ac)
344+ }
330345 }
331346
332347 // NotifyGlobal raises a daemon-wide notice from outside the daemon package.
@@ -334,27 +349,33 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode,
334349 d.notify(nil, sev, code, msg, cause)
335350 }
336351
337-// showToast publishes a notice into one client's toast stack. An identical
338-// code within the same scope coalesces into the existing entry and restarts its
339-// TTL; otherwise the notice is prepended and the stack trimmed. The repaint is
340-// issued after noticeMu is released.
352+// showToast publishes a notice into one client's toast stack and repaints only
353+// after the state mutation is complete.
341354 func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) {
355+ if d.publishToast(ac, n) {
356+ d.repaintForNotice(ac)
357+ }
358+}
359+
360+// publishToast durably mutates one client's toast state without repainting.
361+// Routing paths call it while routingMu excludes detach, then release routingMu
362+// before rendering so a render failure can safely route another notice.
363+func (d *Daemon) publishToast(ac *attachedClient, n domain.Notification) bool {
342364 if ac == nil {
343- return
365+ return false
344366 }
345367 ac.initOverlays()
346368 rt := ac.overlays
347369
348370 rt.noticeMu.Lock()
371+ defer rt.noticeMu.Unlock()
349372 if i := rt.indexOfToastLocked(n.Code, n.SessionID); i >= 0 {
350373 rt.noticeToasts[i].n.Count += n.Count
351374 rt.noticeToasts[i].n.Time = n.Time
352375 rt.noticeToasts[i].n.Details = n.Details
353376 rt.noticeToasts[i].timer.stop()
354377 d.retainToastTimerLocked(ac, &rt.noticeToasts[i])
355- rt.noticeMu.Unlock()
356- d.repaintForNotice(ac)
357- return
378+ return true
358379 }
359380
360381 toast := noticeToast{n: n}
@@ -367,8 +388,7 @@ func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) {
367388 rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts]
368389 }
369390 d.retainToastTimerLocked(ac, &rt.noticeToasts[0])
370- rt.noticeMu.Unlock()
371- d.repaintForNotice(ac)
391+ return true
372392 }
373393
374394 // indexOfToastLocked finds a visible toast with the same code and scope.
@@ -41,6 +41,9 @@ type noticeCenter struct {
41 // beforeGlobalDelivery is a deterministic test seam after targets have been41 // beforeGlobalDelivery is a deterministic test seam after targets have been
42 // selected and before their toast stacks are touched.42 // selected and before their toast stacks are touched.
43 beforeGlobalDelivery func()43 beforeGlobalDelivery func()
44+ // beforeSessionDelivery is a deterministic test seam after a session client
45+ // has been selected and before its toast stack is touched.
46+ beforeSessionDelivery func()
44 // beforeClientNoticeMutation is a deterministic test seam after a client47 // beforeClientNoticeMutation is a deterministic test seam after a client
45 // notice's ownership validation and before its toast state is changed.48 // notice's ownership validation and before its toast state is changed.
46 beforeClientNoticeMutation func()49 beforeClientNoticeMutation func()
@@ -254,11 +257,16 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
254 d.notices.routingMu.Unlock()257 d.notices.routingMu.Unlock()
255 return258 return
256 }259 }
257- // Live session-scoped notices do not use the pending queue, so release260+ if d.notices.beforeSessionDelivery != nil {
258- // routing before painting. Painting can itself report an error, which261+ d.notices.beforeSessionDelivery()
259- // may recursively route a notice.262+ }
263+ // Publish the toast while detach is excluded, then release routing before
264+ // repainting. Rendering can report another notice and re-enter routing.
265+ published := d.publishToast(ac, n)
260 d.notices.routingMu.Unlock()266 d.notices.routingMu.Unlock()
261- d.showToast(ac, n)267+ if published {
268+ d.repaintForNotice(ac)
269+ }
262 return270 return
263 }271 }
264 272
@@ -266,10 +274,9 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
266 }274 }
267 275
268 // deliverGlobal atomically selects all current attachments and either publishes276 // deliverGlobal atomically selects all current attachments and either publishes
269-// to all of them or queues the notice. routingMu is deliberately held through277+// to all of them or queues the notice. routingMu is held through toast-state
270-// showToast, but never with d.mu or sess.mu held: detach cannot turn a selected278+// publication, but never through repaint: detach cannot turn a selected target
271-// target stale between selection and publication, and firstPaint cannot drain279+// stale before publication, while rendering remains free to re-enter routing.
272-// before an unattached route has queued its notice.
273 func (d *Daemon) deliverGlobal(n domain.Notification) {280 func (d *Daemon) deliverGlobal(n domain.Notification) {
274 d.mu.Lock()281 d.mu.Lock()
275 d.notices.routingMu.Lock()282 d.notices.routingMu.Lock()
@@ -278,28 +285,31 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
278 sessions = append(sessions, s)285 sessions = append(sessions, s)
279 }286 }
280 d.mu.Unlock()287 d.mu.Unlock()
281- defer d.notices.routingMu.Unlock()
282 288
283 if d.notices.beforeGlobalDelivery != nil {289 if d.notices.beforeGlobalDelivery != nil {
284 d.notices.beforeGlobalDelivery()290 d.notices.beforeGlobalDelivery()
285 }291 }
286- delivered := false292+ targets := make([]*attachedClient, 0, len(sessions))
287 for _, s := range sessions {293 for _, s := range sessions {
288 s.mu.Lock()294 s.mu.Lock()
289 ac := s.client295 ac := s.client
290 s.mu.Unlock()296 s.mu.Unlock()
291- if ac == nil {297+ if ac == nil || !d.publishToast(ac, n) {
292 continue298 continue
293 }299 }
294- d.showToast(ac, n)300+ targets = append(targets, ac)
295- delivered = true
296 }301 }
297- if !delivered {302+ if len(targets) == 0 {
298 if d.notices.beforeQueueGlobal != nil {303 if d.notices.beforeQueueGlobal != nil {
299 d.notices.beforeQueueGlobal()304 d.notices.beforeQueueGlobal()
300 }305 }
301 d.notices.queueGlobal(n)306 d.notices.queueGlobal(n)
302 }307 }
308+ d.notices.routingMu.Unlock()
309+
310+ for _, ac := range targets {
311+ d.repaintForNotice(ac)
312+ }
303 }313 }
304 314
305 // drainPendingForFirstPaint publishes pending globals and notices scoped to315 // drainPendingForFirstPaint publishes pending globals and notices scoped to
@@ -308,18 +318,19 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
308 // drain has already happened.318 // drain has already happened.
309 func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {319 func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
310 d.notices.routingMu.Lock()320 d.notices.routingMu.Lock()
311- defer d.notices.routingMu.Unlock()
312 321
313 sess.mu.Lock()322 sess.mu.Lock()
314 current := sess.client == ac323 current := sess.client == ac
315 sess.mu.Unlock()324 sess.mu.Unlock()
316 if !current {325 if !current {
326+ d.notices.routingMu.Unlock()
317 return327 return
318 }328 }
329+ published := false
319 var keep []domain.Notification330 var keep []domain.Notification
320 for _, n := range d.notices.drainPending() {331 for _, n := range d.notices.drainPending() {
321 if n.SessionID == "" || n.SessionID == sess.id {332 if n.SessionID == "" || n.SessionID == sess.id {
322- d.showToast(ac, n)333+ published = d.publishToast(ac, n) || published
323 continue334 continue
324 }335 }
325 keep = append(keep, n)336 keep = append(keep, n)
@@ -327,6 +338,10 @@ func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
327 for _, n := range keep {338 for _, n := range keep {
328 d.notices.queueGlobal(n)339 d.notices.queueGlobal(n)
329 }340 }
341+ d.notices.routingMu.Unlock()
342+ if published {
343+ d.repaintForNotice(ac)
344+ }
330 }345 }
331 346
332 // NotifyGlobal raises a daemon-wide notice from outside the daemon package.347 // NotifyGlobal raises a daemon-wide notice from outside the daemon package.
@@ -334,27 +349,33 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode,
334 d.notify(nil, sev, code, msg, cause)349 d.notify(nil, sev, code, msg, cause)
335 }350 }
336 351
337-// showToast publishes a notice into one client's toast stack. An identical352+// showToast publishes a notice into one client's toast stack and repaints only
338-// code within the same scope coalesces into the existing entry and restarts its353+// after the state mutation is complete.
339-// TTL; otherwise the notice is prepended and the stack trimmed. The repaint is
340-// issued after noticeMu is released.
341 func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) {354 func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) {
355+ if d.publishToast(ac, n) {
356+ d.repaintForNotice(ac)
357+ }
358+}
359+
360+// publishToast durably mutates one client's toast state without repainting.
361+// Routing paths call it while routingMu excludes detach, then release routingMu
362+// before rendering so a render failure can safely route another notice.
363+func (d *Daemon) publishToast(ac *attachedClient, n domain.Notification) bool {
342 if ac == nil {364 if ac == nil {
343- return365+ return false
344 }366 }
345 ac.initOverlays()367 ac.initOverlays()
346 rt := ac.overlays368 rt := ac.overlays
347 369
348 rt.noticeMu.Lock()370 rt.noticeMu.Lock()
371+ defer rt.noticeMu.Unlock()
349 if i := rt.indexOfToastLocked(n.Code, n.SessionID); i >= 0 {372 if i := rt.indexOfToastLocked(n.Code, n.SessionID); i >= 0 {
350 rt.noticeToasts[i].n.Count += n.Count373 rt.noticeToasts[i].n.Count += n.Count
351 rt.noticeToasts[i].n.Time = n.Time374 rt.noticeToasts[i].n.Time = n.Time
352 rt.noticeToasts[i].n.Details = n.Details375 rt.noticeToasts[i].n.Details = n.Details
353 rt.noticeToasts[i].timer.stop()376 rt.noticeToasts[i].timer.stop()
354 d.retainToastTimerLocked(ac, &rt.noticeToasts[i])377 d.retainToastTimerLocked(ac, &rt.noticeToasts[i])
355- rt.noticeMu.Unlock()378+ return true
356- d.repaintForNotice(ac)
357- return
358 }379 }
359 380
360 toast := noticeToast{n: n}381 toast := noticeToast{n: n}
@@ -367,8 +388,7 @@ func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) {
367 rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts]388 rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts]
368 }389 }
369 d.retainToastTimerLocked(ac, &rt.noticeToasts[0])390 d.retainToastTimerLocked(ac, &rt.noticeToasts[0])
370- rt.noticeMu.Unlock()391+ return true
371- d.repaintForNotice(ac)
372 }392 }
373 393
374 // indexOfToastLocked finds a visible toast with the same code and scope.394 // indexOfToastLocked finds a visible toast with the same code and scope.
M internal/usecase/daemon/notify_test.go +57 -0
@@ -525,6 +525,63 @@ func TestNotifyRoutesToSessionClientOnly(t *testing.T) {
525525 require.Empty(t, otherToasts, "another session's client must be untouched by session-scoped notices")
526526 }
527527
528+func TestNotifySessionScopedSerializesDetachThroughToastPublication(t *testing.T) {
529+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
530+
531+ selected := make(chan struct{})
532+ releasePublication := make(chan struct{})
533+ d.notices.beforeSessionDelivery = func() {
534+ close(selected)
535+ <-releasePublication
536+ }
537+
538+ notifyDone := make(chan struct{})
539+ go func() {
540+ d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "hello", nil)
541+ close(notifyDone)
542+ }()
543+ <-selected
544+
545+ detachStarted := make(chan struct{})
546+ detachDone := make(chan bool, 1)
547+ go func() {
548+ close(detachStarted)
549+ detachDone <- d.detachIfCurrent(sess, ac)
550+ }()
551+ <-detachStarted
552+ select {
553+ case <-detachDone:
554+ t.Fatal("detach completed before the selected toast was durably published")
555+ default:
556+ }
557+
558+ close(releasePublication)
559+ <-notifyDone
560+ require.True(t, <-detachDone)
561+
562+ toasts, _ := visibleToasts(ac)
563+ require.Len(t, toasts, 1)
564+ require.Equal(t, "hello", toasts[0].Message)
565+ require.Empty(t, d.notices.drainPending(), "a toast published before detach must not also be queued")
566+}
567+
568+func TestNotifySessionScopedReleasesRoutingBeforeRepaint(t *testing.T) {
569+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
570+
571+ repaintObserved := make(chan bool, 1)
572+ ac.renderStages.capture = func() {
573+ unlocked := d.notices.routingMu.TryLock()
574+ if unlocked {
575+ d.notices.routingMu.Unlock()
576+ }
577+ repaintObserved <- unlocked
578+ }
579+
580+ d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "hello", nil)
581+
582+ require.True(t, <-repaintObserved, "notice repaint must run without routingMu held")
583+}
584+
528585 func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
529586 d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock())
530587 sessA.mu.Lock()
@@ -525,6 +525,63 @@ func TestNotifyRoutesToSessionClientOnly(t *testing.T) {
525 require.Empty(t, otherToasts, "another session's client must be untouched by session-scoped notices")525 require.Empty(t, otherToasts, "another session's client must be untouched by session-scoped notices")
526 }526 }
527 527
528+func TestNotifySessionScopedSerializesDetachThroughToastPublication(t *testing.T) {
529+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
530+
531+ selected := make(chan struct{})
532+ releasePublication := make(chan struct{})
533+ d.notices.beforeSessionDelivery = func() {
534+ close(selected)
535+ <-releasePublication
536+ }
537+
538+ notifyDone := make(chan struct{})
539+ go func() {
540+ d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "hello", nil)
541+ close(notifyDone)
542+ }()
543+ <-selected
544+
545+ detachStarted := make(chan struct{})
546+ detachDone := make(chan bool, 1)
547+ go func() {
548+ close(detachStarted)
549+ detachDone <- d.detachIfCurrent(sess, ac)
550+ }()
551+ <-detachStarted
552+ select {
553+ case <-detachDone:
554+ t.Fatal("detach completed before the selected toast was durably published")
555+ default:
556+ }
557+
558+ close(releasePublication)
559+ <-notifyDone
560+ require.True(t, <-detachDone)
561+
562+ toasts, _ := visibleToasts(ac)
563+ require.Len(t, toasts, 1)
564+ require.Equal(t, "hello", toasts[0].Message)
565+ require.Empty(t, d.notices.drainPending(), "a toast published before detach must not also be queued")
566+}
567+
568+func TestNotifySessionScopedReleasesRoutingBeforeRepaint(t *testing.T) {
569+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
570+
571+ repaintObserved := make(chan bool, 1)
572+ ac.renderStages.capture = func() {
573+ unlocked := d.notices.routingMu.TryLock()
574+ if unlocked {
575+ d.notices.routingMu.Unlock()
576+ }
577+ repaintObserved <- unlocked
578+ }
579+
580+ d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "hello", nil)
581+
582+ require.True(t, <-repaintObserved, "notice repaint must run without routingMu held")
583+}
584+
528 func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {585 func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
529 d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock())586 d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock())
530 sessA.mu.Lock()587 sessA.mu.Lock()

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