detached 6ea049e

feat(daemon): queue session-scoped notices for the next attach

commit
6ea049e1b4e602327f23b2df441ad01c52c66ee3
author
brice · · 2026-07-23 21:31 UTC
parent
f7c9bbe
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +88 -26
M internal/domain/notice.go +2 -0
@@ -41,6 +41,7 @@ const (
4141 NoticeClipboardTooLarge
4242 NoticeAutoResume
4343 NoticeConnection
44+ NoticeUser
4445
4546 // noticeCodeLimit is an append-only declaration sentinel, not a persisted
4647 // valid notice code. Keep it last when adding a NoticeCode.
@@ -68,6 +69,7 @@ var noticeSlugs = map[NoticeCode]string{
6869 NoticeClipboardTooLarge: "clipboard-too-large",
6970 NoticeAutoResume: "auto-resume",
7071 NoticeConnection: "connection",
72+ NoticeUser: "user",
7173 }
7274
7375 func (c NoticeCode) String() string {
@@ -41,6 +41,7 @@ const (
41 NoticeClipboardTooLarge41 NoticeClipboardTooLarge
42 NoticeAutoResume42 NoticeAutoResume
43 NoticeConnection43 NoticeConnection
44+ NoticeUser
44 45
45 // noticeCodeLimit is an append-only declaration sentinel, not a persisted46 // noticeCodeLimit is an append-only declaration sentinel, not a persisted
46 // valid notice code. Keep it last when adding a NoticeCode.47 // valid notice code. Keep it last when adding a NoticeCode.
@@ -68,6 +69,7 @@ var noticeSlugs = map[NoticeCode]string{
68 NoticeClipboardTooLarge: "clipboard-too-large",69 NoticeClipboardTooLarge: "clipboard-too-large",
69 NoticeAutoResume: "auto-resume",70 NoticeAutoResume: "auto-resume",
70 NoticeConnection: "connection",71 NoticeConnection: "connection",
72+ NoticeUser: "user",
71 }73 }
72 74
73 func (c NoticeCode) String() string {75 func (c NoticeCode) String() string {
M internal/usecase/daemon/notify.go +34 -16
@@ -25,13 +25,13 @@ const (
2525 var errNoNeighbor = errors.New("no pane in that direction")
2626
2727 // noticeCenter owns the daemon-wide notification history and the queue of
28-// global notices awaiting a first attached client. Its mu is leaf-level: no
29-// other lock is ever taken while holding it.
28+// global or session-scoped notices awaiting an eligible attached client. Its
29+// mu is leaf-level: no other lock is ever taken while holding it.
3030 type noticeCenter struct {
3131 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.
32+ // routingMu serializes notice deliver-or-queue with the first-paint drain.
33+ // Attachment removal also takes it, so a client selected for a toast remains
34+ // current until that toast is published.
3535 routingMu sync.Mutex
3636 ring []domain.Notification
3737 pending []domain.Notification
@@ -112,7 +112,7 @@ func (nc *noticeCenter) queueGlobal(n domain.Notification) {
112112 nc.mu.Lock()
113113 defer nc.mu.Unlock()
114114 for i := range nc.pending {
115- if nc.pending[i].Code == n.Code {
115+ if nc.pending[i].Code == n.Code && nc.pending[i].SessionID == n.SessionID {
116116 nc.pending[i].Count += n.Count
117117 nc.pending[i].Time = n.Time
118118 return
@@ -223,12 +223,13 @@ func (d *Daemon) reportError(sess *session, err error) {
223223
224224 // notify records a notice and routes it to whoever can see it. A nil sess means
225225 // daemon-global: every attached client gets it, and if none is attached the
226-// notice waits in the pending queue for the next attach.
226+// notice waits in the pending queue for the next attach. A detached session's
227+// notice waits for that session's next attach.
227228 //
228229 // Locking: sess.mu and d.mu are only held to snapshot; showToast is always
229-// called with no daemon, session, or pane lock held. Global routing holds the
230-// notice center's routingMu across selection and publication, which serializes
231-// it with attachment detach and pending-queue drains.
230+// called with no daemon, session, or pane lock held. Routing holds the notice
231+// center's routingMu across selection and publication, which serializes it with
232+// attachment detach and pending-queue drains. The order is routingMu -> sess.mu.
232233 func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
233234 n := domain.Notification{
234235 Code: code,
@@ -244,12 +245,20 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
244245 d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
245246
246247 if sess != nil {
248+ d.notices.routingMu.Lock()
247249 sess.mu.Lock()
248250 ac := sess.client
249251 sess.mu.Unlock()
250- if ac != nil {
251- d.showToast(ac, n)
252+ if ac == nil {
253+ d.notices.queueGlobal(n)
254+ d.notices.routingMu.Unlock()
255+ return
252256 }
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+ d.notices.routingMu.Unlock()
261+ d.showToast(ac, n)
253262 return
254263 }
255264
@@ -293,9 +302,10 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
293302 }
294303 }
295304
296-// drainPendingForFirstPaint publishes pending globals only while ac remains the
297-// session's current attachment. It shares routingMu with deliverGlobal, so an
298-// unattached global route cannot queue after this drain has already happened.
305+// drainPendingForFirstPaint publishes pending globals and notices scoped to
306+// sess only while ac remains the session's current attachment. It shares
307+// routingMu with notice routing, so an unattached route cannot queue after this
308+// drain has already happened.
299309 func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
300310 d.notices.routingMu.Lock()
301311 defer d.notices.routingMu.Unlock()
@@ -306,8 +316,16 @@ func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
306316 if !current {
307317 return
308318 }
319+ var keep []domain.Notification
309320 for _, n := range d.notices.drainPending() {
310- d.showToast(ac, n)
321+ if n.SessionID == "" || n.SessionID == sess.id {
322+ d.showToast(ac, n)
323+ continue
324+ }
325+ keep = append(keep, n)
326+ }
327+ for _, n := range keep {
328+ d.notices.queueGlobal(n)
311329 }
312330 }
313331
@@ -25,13 +25,13 @@ const (
25 var errNoNeighbor = errors.New("no pane in that direction")25 var errNoNeighbor = errors.New("no pane in that direction")
26 26
27 // noticeCenter owns the daemon-wide notification history and the queue of27 // noticeCenter owns the daemon-wide notification history and the queue of
28-// global notices awaiting a first attached client. Its mu is leaf-level: no28+// global or session-scoped notices awaiting an eligible attached client. Its
29-// other lock is ever taken while holding it.29+// mu is leaf-level: no other lock is ever taken while holding it.
30 type noticeCenter struct {30 type noticeCenter struct {
31 mu sync.Mutex31 mu sync.Mutex
32- // routingMu serializes global deliver-or-queue with the first-paint drain.32+ // routingMu serializes notice deliver-or-queue with the first-paint drain.
33- // Attachment removal also takes it, so a client selected for a global toast33+ // Attachment removal also takes it, so a client selected for a toast remains
34- // remains current until that toast is published.34+ // current until that toast is published.
35 routingMu sync.Mutex35 routingMu sync.Mutex
36 ring []domain.Notification36 ring []domain.Notification
37 pending []domain.Notification37 pending []domain.Notification
@@ -112,7 +112,7 @@ func (nc *noticeCenter) queueGlobal(n domain.Notification) {
112 nc.mu.Lock()112 nc.mu.Lock()
113 defer nc.mu.Unlock()113 defer nc.mu.Unlock()
114 for i := range nc.pending {114 for i := range nc.pending {
115- if nc.pending[i].Code == n.Code {115+ if nc.pending[i].Code == n.Code && nc.pending[i].SessionID == n.SessionID {
116 nc.pending[i].Count += n.Count116 nc.pending[i].Count += n.Count
117 nc.pending[i].Time = n.Time117 nc.pending[i].Time = n.Time
118 return118 return
@@ -223,12 +223,13 @@ func (d *Daemon) reportError(sess *session, err error) {
223 223
224 // notify records a notice and routes it to whoever can see it. A nil sess means224 // notify records a notice and routes it to whoever can see it. A nil sess means
225 // daemon-global: every attached client gets it, and if none is attached the225 // daemon-global: every attached client gets it, and if none is attached the
226-// notice waits in the pending queue for the next attach.226+// notice waits in the pending queue for the next attach. A detached session's
227+// notice waits for that session's next attach.
227 //228 //
228 // Locking: sess.mu and d.mu are only held to snapshot; showToast is always229 // Locking: sess.mu and d.mu are only held to snapshot; showToast is always
229-// called with no daemon, session, or pane lock held. Global routing holds the230+// called with no daemon, session, or pane lock held. Routing holds the notice
230-// notice center's routingMu across selection and publication, which serializes231+// center's routingMu across selection and publication, which serializes it with
231-// it with attachment detach and pending-queue drains.232+// attachment detach and pending-queue drains. The order is routingMu -> sess.mu.
232 func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {233 func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
233 n := domain.Notification{234 n := domain.Notification{
234 Code: code,235 Code: code,
@@ -244,12 +245,20 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
244 d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)245 d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
245 246
246 if sess != nil {247 if sess != nil {
248+ d.notices.routingMu.Lock()
247 sess.mu.Lock()249 sess.mu.Lock()
248 ac := sess.client250 ac := sess.client
249 sess.mu.Unlock()251 sess.mu.Unlock()
250- if ac != nil {252+ if ac == nil {
251- d.showToast(ac, n)253+ d.notices.queueGlobal(n)
254+ d.notices.routingMu.Unlock()
255+ return
252 }256 }
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+ d.notices.routingMu.Unlock()
261+ d.showToast(ac, n)
253 return262 return
254 }263 }
255 264
@@ -293,9 +302,10 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
293 }302 }
294 }303 }
295 304
296-// drainPendingForFirstPaint publishes pending globals only while ac remains the305+// drainPendingForFirstPaint publishes pending globals and notices scoped to
297-// session's current attachment. It shares routingMu with deliverGlobal, so an306+// sess only while ac remains the session's current attachment. It shares
298-// unattached global route cannot queue after this drain has already happened.307+// routingMu with notice routing, so an unattached route cannot queue after this
308+// drain has already happened.
299 func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {309 func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
300 d.notices.routingMu.Lock()310 d.notices.routingMu.Lock()
301 defer d.notices.routingMu.Unlock()311 defer d.notices.routingMu.Unlock()
@@ -306,8 +316,16 @@ func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
306 if !current {316 if !current {
307 return317 return
308 }318 }
319+ var keep []domain.Notification
309 for _, n := range d.notices.drainPending() {320 for _, n := range d.notices.drainPending() {
310- d.showToast(ac, n)321+ if n.SessionID == "" || n.SessionID == sess.id {
322+ d.showToast(ac, n)
323+ continue
324+ }
325+ keep = append(keep, n)
326+ }
327+ for _, n := range keep {
328+ d.notices.queueGlobal(n)
311 }329 }
312 }330 }
313 331
M internal/usecase/daemon/notify_test.go +52 -10
@@ -525,18 +525,60 @@ 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 TestNotifySessionWithoutClientRecordsHistoryOnly(t *testing.T) {
529- d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
530- sess.mu.Lock()
531- sess.client = nil
532- sess.mu.Unlock()
528+func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
529+ d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock())
530+ sessA.mu.Lock()
531+ sessA.client = nil
532+ sessA.mu.Unlock()
533+
534+ clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
535+ clientB.initOverlays()
536+ sessB := &session{id: "manual-2", name: "other", ctx: sessA.ctx, cancel: func() {}}
537+ clientB.setSession(sessB)
538+ d.mu.Lock()
539+ d.sessions[sessB.id] = sessB
540+ d.mu.Unlock()
533541
534- d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
542+ d.notify(sessA, domain.NoticeInfo, domain.NoticeUser, "hello from a script", nil)
543+ // Use the same code globally to prove pending coalescing includes scope.
544+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeUser, "global announcement", nil)
535545
536- require.Len(t, d.notices.history(), 1)
537- toasts, _ := visibleToasts(ac)
538- require.Empty(t, toasts, "a detached session must not paint a toast")
539- require.Empty(t, d.notices.drainPending(), "session-scoped notices are never queued as pending globals")
546+ history := d.notices.history()
547+ require.Len(t, history, 2)
548+ require.Equal(t, domain.NoticeUser, history[1].Code)
549+ oldToasts, _ := visibleToasts(oldA)
550+ require.Empty(t, oldToasts, "a detached session must not paint a toast")
551+
552+ sessB.mu.Lock()
553+ sessB.client = clientB
554+ sessB.mu.Unlock()
555+ d.drainPendingForFirstPaint(sessB, clientB)
556+ bToasts, _ := visibleToasts(clientB)
557+ require.Len(t, bToasts, 1, "another session receives only the global notice")
558+ require.Equal(t, domain.NoticeUser, bToasts[0].Code)
559+ require.Empty(t, bToasts[0].SessionID)
560+ require.Equal(t, "global announcement", bToasts[0].Message)
561+
562+ sessA.mu.Lock()
563+ sessA.client = oldA
564+ sessA.mu.Unlock()
565+ d.drainPendingForFirstPaint(sessA, oldA)
566+ aToasts, _ := visibleToasts(oldA)
567+ require.Len(t, aToasts, 1)
568+ require.Equal(t, domain.NoticeUser, aToasts[0].Code)
569+ require.Equal(t, sessA.id, aToasts[0].SessionID)
570+ require.Equal(t, "hello from a script", aToasts[0].Message)
571+ require.Equal(t, 1, aToasts[0].Count)
572+
573+ secondA := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
574+ secondA.initOverlays()
575+ secondA.setSession(sessA)
576+ sessA.mu.Lock()
577+ sessA.client = secondA
578+ sessA.mu.Unlock()
579+ d.drainPendingForFirstPaint(sessA, secondA)
580+ secondToasts, _ := visibleToasts(secondA)
581+ require.Empty(t, secondToasts, "a drained session notice must not be delivered twice")
540582 }
541583
542584 func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
@@ -525,18 +525,60 @@ 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 TestNotifySessionWithoutClientRecordsHistoryOnly(t *testing.T) {528+func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
529- d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())529+ d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock())
530- sess.mu.Lock()530+ sessA.mu.Lock()
531- sess.client = nil531+ sessA.client = nil
532- sess.mu.Unlock()532+ sessA.mu.Unlock()
533+
534+ clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
535+ clientB.initOverlays()
536+ sessB := &session{id: "manual-2", name: "other", ctx: sessA.ctx, cancel: func() {}}
537+ clientB.setSession(sessB)
538+ d.mu.Lock()
539+ d.sessions[sessB.id] = sessB
540+ d.mu.Unlock()
533 541
534- d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)542+ d.notify(sessA, domain.NoticeInfo, domain.NoticeUser, "hello from a script", nil)
543+ // Use the same code globally to prove pending coalescing includes scope.
544+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeUser, "global announcement", nil)
535 545
536- require.Len(t, d.notices.history(), 1)546+ history := d.notices.history()
537- toasts, _ := visibleToasts(ac)547+ require.Len(t, history, 2)
538- require.Empty(t, toasts, "a detached session must not paint a toast")548+ require.Equal(t, domain.NoticeUser, history[1].Code)
539- require.Empty(t, d.notices.drainPending(), "session-scoped notices are never queued as pending globals")549+ oldToasts, _ := visibleToasts(oldA)
550+ require.Empty(t, oldToasts, "a detached session must not paint a toast")
551+
552+ sessB.mu.Lock()
553+ sessB.client = clientB
554+ sessB.mu.Unlock()
555+ d.drainPendingForFirstPaint(sessB, clientB)
556+ bToasts, _ := visibleToasts(clientB)
557+ require.Len(t, bToasts, 1, "another session receives only the global notice")
558+ require.Equal(t, domain.NoticeUser, bToasts[0].Code)
559+ require.Empty(t, bToasts[0].SessionID)
560+ require.Equal(t, "global announcement", bToasts[0].Message)
561+
562+ sessA.mu.Lock()
563+ sessA.client = oldA
564+ sessA.mu.Unlock()
565+ d.drainPendingForFirstPaint(sessA, oldA)
566+ aToasts, _ := visibleToasts(oldA)
567+ require.Len(t, aToasts, 1)
568+ require.Equal(t, domain.NoticeUser, aToasts[0].Code)
569+ require.Equal(t, sessA.id, aToasts[0].SessionID)
570+ require.Equal(t, "hello from a script", aToasts[0].Message)
571+ require.Equal(t, 1, aToasts[0].Count)
572+
573+ secondA := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
574+ secondA.initOverlays()
575+ secondA.setSession(sessA)
576+ sessA.mu.Lock()
577+ sessA.client = secondA
578+ sessA.mu.Unlock()
579+ d.drainPendingForFirstPaint(sessA, secondA)
580+ secondToasts, _ := visibleToasts(secondA)
581+ require.Empty(t, secondToasts, "a drained session notice must not be delivered twice")
540 }582 }
541 583
542 func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {584 func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {

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