feat(daemon): queue session-scoped notices for the next attach
- commit
- 6ea049e1b4e602327f23b2df441ad01c52c66ee3
- author
- brice · · 2026-07-23 21:31 UTC
- parent
- f7c9bbe
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +88 -26
M internal/domain/notice.go +2 -0
| @@ -41,6 +41,7 @@ const ( | |||
| 41 | 41 | NoticeClipboardTooLarge | |
| 42 | 42 | NoticeAutoResume | |
| 43 | 43 | NoticeConnection | |
| 44 | + | NoticeUser | |
| 44 | 45 | ||
| 45 | 46 | // noticeCodeLimit is an append-only declaration sentinel, not a persisted | |
| 46 | 47 | // valid notice code. Keep it last when adding a NoticeCode. | |
| @@ -68,6 +69,7 @@ var noticeSlugs = map[NoticeCode]string{ | |||
| 68 | 69 | NoticeClipboardTooLarge: "clipboard-too-large", | |
| 69 | 70 | NoticeAutoResume: "auto-resume", | |
| 70 | 71 | NoticeConnection: "connection", | |
| 72 | + | NoticeUser: "user", | |
| 71 | 73 | } | |
| 72 | 74 | ||
| 73 | 75 | func (c NoticeCode) String() string { | |
| @@ -41,6 +41,7 @@ const ( | |||||
| 41 | NoticeClipboardTooLarge | 41 | NoticeClipboardTooLarge | ||
| 42 | NoticeAutoResume | 42 | NoticeAutoResume | ||
| 43 | NoticeConnection | 43 | NoticeConnection | ||
| 44 | + | NoticeUser | |||
| 44 | 45 | ||||
| 45 | // noticeCodeLimit is an append-only declaration sentinel, not a persisted | 46 | // 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 ( | |||
| 25 | 25 | var errNoNeighbor = errors.New("no pane in that direction") | |
| 26 | 26 | ||
| 27 | 27 | // 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. | |
| 30 | 30 | type noticeCenter struct { | |
| 31 | 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. | |
| 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. | |
| 35 | 35 | routingMu sync.Mutex | |
| 36 | 36 | ring []domain.Notification | |
| 37 | 37 | pending []domain.Notification | |
| @@ -112,7 +112,7 @@ func (nc *noticeCenter) queueGlobal(n domain.Notification) { | |||
| 112 | 112 | nc.mu.Lock() | |
| 113 | 113 | defer nc.mu.Unlock() | |
| 114 | 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 | 116 | nc.pending[i].Count += n.Count | |
| 117 | 117 | nc.pending[i].Time = n.Time | |
| 118 | 118 | return | |
| @@ -223,12 +223,13 @@ func (d *Daemon) reportError(sess *session, err error) { | |||
| 223 | 223 | ||
| 224 | 224 | // notify records a notice and routes it to whoever can see it. A nil sess means | |
| 225 | 225 | // 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 | 229 | // 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. | |
| 232 | 233 | func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) { | |
| 233 | 234 | n := domain.Notification{ | |
| 234 | 235 | Code: code, | |
| @@ -244,12 +245,20 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No | |||
| 244 | 245 | d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause) | |
| 245 | 246 | ||
| 246 | 247 | if sess != nil { | |
| 248 | + | d.notices.routingMu.Lock() | |
| 247 | 249 | sess.mu.Lock() | |
| 248 | 250 | ac := sess.client | |
| 249 | 251 | 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 | |
| 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 | 262 | 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 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. | |
| 299 | 309 | func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) { | |
| 300 | 310 | d.notices.routingMu.Lock() | |
| 301 | 311 | defer d.notices.routingMu.Unlock() | |
| @@ -306,8 +316,16 @@ func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) { | |||
| 306 | 316 | if !current { | |
| 307 | 317 | return | |
| 308 | 318 | } | |
| 319 | + | var keep []domain.Notification | |
| 309 | 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 | ||
| @@ -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 of | 27 | // 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 | 28 | + | // 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.Mutex | 31 | 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 toast | 33 | + | // 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.Mutex | 35 | routingMu sync.Mutex | ||
| 36 | ring []domain.Notification | 36 | ring []domain.Notification | ||
| 37 | pending []domain.Notification | 37 | 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.Count | 116 | nc.pending[i].Count += n.Count | ||
| 117 | nc.pending[i].Time = n.Time | 117 | nc.pending[i].Time = n.Time | ||
| 118 | return | 118 | 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 means | 224 | // 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 the | 225 | // 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 always | 229 | // 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 | + | // called with no daemon, session, or pane lock held. Routing holds the notice |
| 230 | - | // notice center's routingMu across selection and publication, which serializes | 231 | + | // 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.client | 250 | 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 | return | 262 | 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 the | 305 | + | // drainPendingForFirstPaint publishes pending globals and notices scoped to |
| 297 | - | // session's current attachment. It shares routingMu with deliverGlobal, so an | 306 | + | // 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 | return | 317 | 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) { | |||
| 525 | 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) { | |
| 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() | |
| 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) | |
| 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") | |
| 540 | 582 | } | |
| 541 | 583 | ||
| 542 | 584 | 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 = nil | 531 | + | 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) { | ||