feat(daemon): route user notices to per-client toast state
Add reportError/notify/NotifyGlobal on Daemon, a per-client toast stack on overlayRuntime, and a pending-global drain on first paint. reportError classifies via errors.As into *domain.UserError and falls back to NoticeInternal, so an error is never silently dropped unless it is benign (context.Canceled, errNoNeighbor, ports.ErrNoClipboardImage). Session-scoped notices go to sess.client; global notices fan out over a two-level snapshot and queue when nothing is attached. noticeMu is innermost: it guards noticeToasts, noticeOverflow, and noticeSeq, and every repaint is issued after it is released. Toasts coalesce by code plus scope, cap at 3 newest-first with an overflow counter, and expire on d.clock at 4s/6s/8s by severity.
- commit
- 57a5dbb833e587f9fb08a67d11378c537a7f57e2
- author
- brice · · 2026-07-20 12:25 UTC
- parent
- 08a23ac
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +694 -0
M internal/usecase/daemon/client.go +6 -0
| @@ -589,6 +589,12 @@ func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecyc | |||
| 589 | 589 | // emits a full redraw. Attach must not wait for the resize-idle fallback | |
| 590 | 590 | // timer. | |
| 591 | 591 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | |
| 592 | + | // Global notices raised while nothing was attached surface on this client. | |
| 593 | + | // Drained before the early return below so a session without an active tab | |
| 594 | + | // cannot swallow the queue. | |
| 595 | + | for _, n := range d.notices.drainPending() { | |
| 596 | + | d.showToast(ac, n) | |
| 597 | + | } | |
| 592 | 598 | tb := sess.activeTab() | |
| 593 | 599 | if tb == nil { | |
| 594 | 600 | return | |
| @@ -589,6 +589,12 @@ func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecyc | |||||
| 589 | // emits a full redraw. Attach must not wait for the resize-idle fallback | 589 | // emits a full redraw. Attach must not wait for the resize-idle fallback | ||
| 590 | // timer. | 590 | // timer. | ||
| 591 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | 591 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | ||
| 592 | + | // Global notices raised while nothing was attached surface on this client. | |||
| 593 | + | // Drained before the early return below so a session without an active tab | |||
| 594 | + | // cannot swallow the queue. | |||
| 595 | + | for _, n := range d.notices.drainPending() { | |||
| 596 | + | d.showToast(ac, n) | |||
| 597 | + | } | |||
| 592 | tb := sess.activeTab() | 598 | tb := sess.activeTab() | ||
| 593 | if tb == nil { | 599 | if tb == nil { | ||
| 594 | return | 600 | return | ||
M internal/usecase/daemon/daemon.go +2 -0
| @@ -146,6 +146,7 @@ type Daemon struct { | |||
| 146 | 146 | tabsConfig atomic.Pointer[domain.TabsConfig] | |
| 147 | 147 | themeConfig atomic.Pointer[themeConfigSnapshot] | |
| 148 | 148 | barScripts *barScriptState | |
| 149 | + | notices *noticeCenter | |
| 149 | 150 | resumeParkGrace time.Duration | |
| 150 | 151 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | |
| 151 | 152 | // (see clipboard.go); empty means use os.TempDir(). | |
| @@ -350,6 +351,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 350 | 351 | animWake: make(chan struct{}, 1), | |
| 351 | 352 | snapshotMarshal: snapcodec.Marshal, | |
| 352 | 353 | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | |
| 354 | + | notices: newNoticeCenter(), | |
| 353 | 355 | resumeParkGrace: defaultResumeParkGrace, | |
| 354 | 356 | barScripts: &barScriptState{ | |
| 355 | 357 | cfg: barConfigFromDomain(domain.Defaults().Bar), | |
| @@ -146,6 +146,7 @@ type Daemon struct { | |||||
| 146 | tabsConfig atomic.Pointer[domain.TabsConfig] | 146 | tabsConfig atomic.Pointer[domain.TabsConfig] | ||
| 147 | themeConfig atomic.Pointer[themeConfigSnapshot] | 147 | themeConfig atomic.Pointer[themeConfigSnapshot] | ||
| 148 | barScripts *barScriptState | 148 | barScripts *barScriptState | ||
| 149 | + | notices *noticeCenter | |||
| 149 | resumeParkGrace time.Duration | 150 | resumeParkGrace time.Duration | ||
| 150 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | 151 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | ||
| 151 | // (see clipboard.go); empty means use os.TempDir(). | 152 | // (see clipboard.go); empty means use os.TempDir(). | ||
| @@ -350,6 +351,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 350 | animWake: make(chan struct{}, 1), | 351 | animWake: make(chan struct{}, 1), | ||
| 351 | snapshotMarshal: snapcodec.Marshal, | 352 | snapshotMarshal: snapcodec.Marshal, | ||
| 352 | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | 353 | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | ||
| 354 | + | notices: newNoticeCenter(), | |||
| 353 | resumeParkGrace: defaultResumeParkGrace, | 355 | resumeParkGrace: defaultResumeParkGrace, | ||
| 354 | barScripts: &barScriptState{ | 356 | barScripts: &barScriptState{ | ||
| 355 | cfg: barConfigFromDomain(domain.Defaults().Bar), | 357 | cfg: barConfigFromDomain(domain.Defaults().Bar), | ||
M internal/usecase/daemon/notify.go +212 -0
| @@ -1,16 +1,28 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "strings" | |
| 4 | 7 | "sync" | |
| 8 | + | "time" | |
| 5 | 9 | ||
| 6 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | 12 | ) | |
| 8 | 13 | ||
| 9 | 14 | const ( | |
| 10 | 15 | noticeHistoryCap = 200 | |
| 11 | 16 | noticePendingCap = 32 | |
| 17 | + | // maxVisibleToasts bounds the stack drawn over a client's screen; anything | |
| 18 | + | // trimmed is counted in noticeOverflow and stays reachable in history. | |
| 19 | + | maxVisibleToasts = 3 | |
| 12 | 20 | ) | |
| 13 | 21 | ||
| 22 | + | // errNoNeighbor reports a directional focus move with nothing to move to. It is | |
| 23 | + | // an ordinary outcome of navigation, never a user-facing failure. | |
| 24 | + | var errNoNeighbor = errors.New("no pane in that direction") | |
| 25 | + | ||
| 14 | 26 | // noticeCenter owns the daemon-wide notification history and the queue of | |
| 15 | 27 | // global notices awaiting a first attached client. Its mu is leaf-level: no | |
| 16 | 28 | // other lock is ever taken while holding it. | |
| @@ -80,3 +92,203 @@ func (nc *noticeCenter) drainPending() []domain.Notification { | |||
| 80 | 92 | nc.pending = nil | |
| 81 | 93 | return out | |
| 82 | 94 | } | |
| 95 | + | ||
| 96 | + | // noticeToast is one entry in a client's visible toast stack. seq identifies the | |
| 97 | + | // entry across coalescing so a stale expiry timer removes only its own toast. | |
| 98 | + | type noticeToast struct { | |
| 99 | + | n domain.Notification | |
| 100 | + | seq uint64 | |
| 101 | + | timer pendingByteTimer | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // noticeTTL is how long a toast of this severity stays visible. | |
| 105 | + | func noticeTTL(sev domain.NoticeSeverity) time.Duration { | |
| 106 | + | switch sev { | |
| 107 | + | case domain.NoticeInfo: | |
| 108 | + | return 4 * time.Second | |
| 109 | + | case domain.NoticeWarn: | |
| 110 | + | return 6 * time.Second | |
| 111 | + | default: | |
| 112 | + | return 8 * time.Second | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // noticeDetails renders the full Unwrap chain for the yank/history views. The | |
| 117 | + | // toast itself never shows this — only the UserError's own message. | |
| 118 | + | func noticeDetails(err error) string { | |
| 119 | + | if err == nil { | |
| 120 | + | return "" | |
| 121 | + | } | |
| 122 | + | var parts []string | |
| 123 | + | for e := err; e != nil; e = errors.Unwrap(e) { | |
| 124 | + | parts = append(parts, e.Error()) | |
| 125 | + | } | |
| 126 | + | return strings.Join(parts, " ← ") | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // benignNoticeError reports errors that are expected control flow rather than | |
| 130 | + | // something the user needs to be told about. | |
| 131 | + | func benignNoticeError(err error) bool { | |
| 132 | + | return errors.Is(err, context.Canceled) || | |
| 133 | + | errors.Is(err, errNoNeighbor) || | |
| 134 | + | errors.Is(err, ports.ErrNoClipboardImage) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // reportError turns any error into a user-facing notice. Unclassified errors | |
| 138 | + | // become NoticeInternal: an error reaching here is never silently dropped | |
| 139 | + | // unless benignNoticeError says it is routine. | |
| 140 | + | func (d *Daemon) reportError(sess *session, err error) { | |
| 141 | + | if err == nil || benignNoticeError(err) { | |
| 142 | + | return | |
| 143 | + | } | |
| 144 | + | var ue *domain.UserError | |
| 145 | + | if errors.As(err, &ue) { | |
| 146 | + | d.notify(sess, ue.Severity, ue.Code, ue.Msg, ue.Err) | |
| 147 | + | return | |
| 148 | + | } | |
| 149 | + | d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // notify records a notice and routes it to whoever can see it. A nil sess means | |
| 153 | + | // daemon-global: every attached client gets it, and if none is attached the | |
| 154 | + | // notice waits in the pending queue for the next attach. | |
| 155 | + | // | |
| 156 | + | // Locking: sess.mu and d.mu are only held to snapshot; showToast is always | |
| 157 | + | // called with no daemon, session, or pane lock held. | |
| 158 | + | func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) { | |
| 159 | + | n := domain.Notification{ | |
| 160 | + | Code: code, | |
| 161 | + | Severity: sev, | |
| 162 | + | Message: msg, | |
| 163 | + | Details: noticeDetails(cause), | |
| 164 | + | Time: d.clock.Now(), | |
| 165 | + | } | |
| 166 | + | if sess != nil { | |
| 167 | + | n.SessionID = sess.id | |
| 168 | + | } | |
| 169 | + | n = d.notices.record(n) | |
| 170 | + | d.log.Warn("user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause) | |
| 171 | + | ||
| 172 | + | if sess != nil { | |
| 173 | + | sess.mu.Lock() | |
| 174 | + | ac := sess.client | |
| 175 | + | sess.mu.Unlock() | |
| 176 | + | if ac != nil { | |
| 177 | + | d.showToast(ac, n) | |
| 178 | + | } | |
| 179 | + | return | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | d.mu.Lock() | |
| 183 | + | sessions := make([]*session, 0, len(d.sessions)) | |
| 184 | + | for _, s := range d.sessions { | |
| 185 | + | sessions = append(sessions, s) | |
| 186 | + | } | |
| 187 | + | d.mu.Unlock() | |
| 188 | + | ||
| 189 | + | delivered := false | |
| 190 | + | for _, s := range sessions { | |
| 191 | + | s.mu.Lock() | |
| 192 | + | ac := s.client | |
| 193 | + | s.mu.Unlock() | |
| 194 | + | if ac != nil { | |
| 195 | + | d.showToast(ac, n) | |
| 196 | + | delivered = true | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | if !delivered { | |
| 200 | + | d.notices.queueGlobal(n) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // NotifyGlobal raises a daemon-wide notice from outside the daemon package. | |
| 205 | + | func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) { | |
| 206 | + | d.notify(nil, sev, code, msg, cause) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // showToast publishes a notice into one client's toast stack. An identical | |
| 210 | + | // code within the same scope coalesces into the existing entry and restarts its | |
| 211 | + | // TTL; otherwise the notice is prepended and the stack trimmed. The repaint is | |
| 212 | + | // issued after noticeMu is released. | |
| 213 | + | func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) { | |
| 214 | + | if ac == nil { | |
| 215 | + | return | |
| 216 | + | } | |
| 217 | + | ac.initOverlays() | |
| 218 | + | rt := ac.overlays | |
| 219 | + | ||
| 220 | + | rt.noticeMu.Lock() | |
| 221 | + | if i := rt.indexOfToastLocked(n.Code, n.SessionID); i >= 0 { | |
| 222 | + | rt.noticeToasts[i].n.Count += n.Count | |
| 223 | + | rt.noticeToasts[i].n.Time = n.Time | |
| 224 | + | rt.noticeToasts[i].n.Details = n.Details | |
| 225 | + | rt.noticeToasts[i].timer.stop() | |
| 226 | + | d.retainToastTimerLocked(ac, &rt.noticeToasts[i]) | |
| 227 | + | rt.noticeMu.Unlock() | |
| 228 | + | d.repaintForNotice(ac) | |
| 229 | + | return | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | rt.noticeSeq++ | |
| 233 | + | toast := noticeToast{n: n, seq: rt.noticeSeq} | |
| 234 | + | rt.noticeToasts = append([]noticeToast{toast}, rt.noticeToasts...) | |
| 235 | + | if len(rt.noticeToasts) > maxVisibleToasts { | |
| 236 | + | for i := maxVisibleToasts; i < len(rt.noticeToasts); i++ { | |
| 237 | + | rt.noticeToasts[i].timer.stop() | |
| 238 | + | rt.noticeOverflow++ | |
| 239 | + | } | |
| 240 | + | rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts] | |
| 241 | + | } | |
| 242 | + | d.retainToastTimerLocked(ac, &rt.noticeToasts[0]) | |
| 243 | + | rt.noticeMu.Unlock() | |
| 244 | + | d.repaintForNotice(ac) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // indexOfToastLocked finds a visible toast with the same code and scope. | |
| 248 | + | // Callers must hold noticeMu. | |
| 249 | + | func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain.SessionID) int { | |
| 250 | + | for i := range rt.noticeToasts { | |
| 251 | + | if rt.noticeToasts[i].n.Code == code && rt.noticeToasts[i].n.SessionID == sid { | |
| 252 | + | return i | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | return -1 | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the | |
| 259 | + | // timer goroutine re-acquires it and releases it before repainting, so the | |
| 260 | + | // callback never runs with noticeMu held. | |
| 261 | + | func (d *Daemon) retainToastTimerLocked(ac *attachedClient, t *noticeToast) { | |
| 262 | + | rt := ac.overlays | |
| 263 | + | rt.noticeSeq++ | |
| 264 | + | t.seq = rt.noticeSeq | |
| 265 | + | seq := t.seq | |
| 266 | + | t.timer.retain(d.clock, noticeTTL(t.n.Severity), func(ports.Timer) { | |
| 267 | + | rt.noticeMu.Lock() | |
| 268 | + | kept := rt.noticeToasts[:0] | |
| 269 | + | for _, tt := range rt.noticeToasts { | |
| 270 | + | if tt.seq == seq { | |
| 271 | + | continue | |
| 272 | + | } | |
| 273 | + | kept = append(kept, tt) | |
| 274 | + | } | |
| 275 | + | rt.noticeToasts = kept | |
| 276 | + | if len(rt.noticeToasts) == 0 { | |
| 277 | + | rt.noticeOverflow = 0 | |
| 278 | + | } | |
| 279 | + | rt.noticeMu.Unlock() | |
| 280 | + | d.repaintForNotice(ac) | |
| 281 | + | }) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // repaintForNotice asks for an urgent redraw. It must be called with noticeMu | |
| 285 | + | // released: invalidateRender can paint inline, which takes sendMu. | |
| 286 | + | func (d *Daemon) repaintForNotice(ac *attachedClient) { | |
| 287 | + | sess := ac.currentSession() | |
| 288 | + | if sess == nil { | |
| 289 | + | // Mid-handoff or torn down: whoever attaches next repaints anyway, and | |
| 290 | + | // invalidateRender cannot paint without a session. | |
| 291 | + | return | |
| 292 | + | } | |
| 293 | + | d.invalidateRender(sess, ac, true, "notify.go") | |
| 294 | + | } | |
| @@ -1,16 +1,28 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "strings" | |||
| 4 | "sync" | 7 | "sync" | ||
| 8 | + | "time" | |||
| 5 | 9 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 7 | ) | 12 | ) | ||
| 8 | 13 | ||||
| 9 | const ( | 14 | const ( | ||
| 10 | noticeHistoryCap = 200 | 15 | noticeHistoryCap = 200 | ||
| 11 | noticePendingCap = 32 | 16 | noticePendingCap = 32 | ||
| 17 | + | // maxVisibleToasts bounds the stack drawn over a client's screen; anything | |||
| 18 | + | // trimmed is counted in noticeOverflow and stays reachable in history. | |||
| 19 | + | maxVisibleToasts = 3 | |||
| 12 | ) | 20 | ) | ||
| 13 | 21 | ||||
| 22 | + | // errNoNeighbor reports a directional focus move with nothing to move to. It is | |||
| 23 | + | // an ordinary outcome of navigation, never a user-facing failure. | |||
| 24 | + | var errNoNeighbor = errors.New("no pane in that direction") | |||
| 25 | + | ||||
| 14 | // noticeCenter owns the daemon-wide notification history and the queue of | 26 | // noticeCenter owns the daemon-wide notification history and the queue of | ||
| 15 | // global notices awaiting a first attached client. Its mu is leaf-level: no | 27 | // global notices awaiting a first attached client. Its mu is leaf-level: no | ||
| 16 | // other lock is ever taken while holding it. | 28 | // other lock is ever taken while holding it. | ||
| @@ -80,3 +92,203 @@ func (nc *noticeCenter) drainPending() []domain.Notification { | |||||
| 80 | nc.pending = nil | 92 | nc.pending = nil | ||
| 81 | return out | 93 | return out | ||
| 82 | } | 94 | } | ||
| 95 | + | ||||
| 96 | + | // noticeToast is one entry in a client's visible toast stack. seq identifies the | |||
| 97 | + | // entry across coalescing so a stale expiry timer removes only its own toast. | |||
| 98 | + | type noticeToast struct { | |||
| 99 | + | n domain.Notification | |||
| 100 | + | seq uint64 | |||
| 101 | + | timer pendingByteTimer | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // noticeTTL is how long a toast of this severity stays visible. | |||
| 105 | + | func noticeTTL(sev domain.NoticeSeverity) time.Duration { | |||
| 106 | + | switch sev { | |||
| 107 | + | case domain.NoticeInfo: | |||
| 108 | + | return 4 * time.Second | |||
| 109 | + | case domain.NoticeWarn: | |||
| 110 | + | return 6 * time.Second | |||
| 111 | + | default: | |||
| 112 | + | return 8 * time.Second | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // noticeDetails renders the full Unwrap chain for the yank/history views. The | |||
| 117 | + | // toast itself never shows this — only the UserError's own message. | |||
| 118 | + | func noticeDetails(err error) string { | |||
| 119 | + | if err == nil { | |||
| 120 | + | return "" | |||
| 121 | + | } | |||
| 122 | + | var parts []string | |||
| 123 | + | for e := err; e != nil; e = errors.Unwrap(e) { | |||
| 124 | + | parts = append(parts, e.Error()) | |||
| 125 | + | } | |||
| 126 | + | return strings.Join(parts, " ← ") | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // benignNoticeError reports errors that are expected control flow rather than | |||
| 130 | + | // something the user needs to be told about. | |||
| 131 | + | func benignNoticeError(err error) bool { | |||
| 132 | + | return errors.Is(err, context.Canceled) || | |||
| 133 | + | errors.Is(err, errNoNeighbor) || | |||
| 134 | + | errors.Is(err, ports.ErrNoClipboardImage) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // reportError turns any error into a user-facing notice. Unclassified errors | |||
| 138 | + | // become NoticeInternal: an error reaching here is never silently dropped | |||
| 139 | + | // unless benignNoticeError says it is routine. | |||
| 140 | + | func (d *Daemon) reportError(sess *session, err error) { | |||
| 141 | + | if err == nil || benignNoticeError(err) { | |||
| 142 | + | return | |||
| 143 | + | } | |||
| 144 | + | var ue *domain.UserError | |||
| 145 | + | if errors.As(err, &ue) { | |||
| 146 | + | d.notify(sess, ue.Severity, ue.Code, ue.Msg, ue.Err) | |||
| 147 | + | return | |||
| 148 | + | } | |||
| 149 | + | d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // notify records a notice and routes it to whoever can see it. A nil sess means | |||
| 153 | + | // daemon-global: every attached client gets it, and if none is attached the | |||
| 154 | + | // notice waits in the pending queue for the next attach. | |||
| 155 | + | // | |||
| 156 | + | // Locking: sess.mu and d.mu are only held to snapshot; showToast is always | |||
| 157 | + | // called with no daemon, session, or pane lock held. | |||
| 158 | + | func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) { | |||
| 159 | + | n := domain.Notification{ | |||
| 160 | + | Code: code, | |||
| 161 | + | Severity: sev, | |||
| 162 | + | Message: msg, | |||
| 163 | + | Details: noticeDetails(cause), | |||
| 164 | + | Time: d.clock.Now(), | |||
| 165 | + | } | |||
| 166 | + | if sess != nil { | |||
| 167 | + | n.SessionID = sess.id | |||
| 168 | + | } | |||
| 169 | + | n = d.notices.record(n) | |||
| 170 | + | d.log.Warn("user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause) | |||
| 171 | + | ||||
| 172 | + | if sess != nil { | |||
| 173 | + | sess.mu.Lock() | |||
| 174 | + | ac := sess.client | |||
| 175 | + | sess.mu.Unlock() | |||
| 176 | + | if ac != nil { | |||
| 177 | + | d.showToast(ac, n) | |||
| 178 | + | } | |||
| 179 | + | return | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | d.mu.Lock() | |||
| 183 | + | sessions := make([]*session, 0, len(d.sessions)) | |||
| 184 | + | for _, s := range d.sessions { | |||
| 185 | + | sessions = append(sessions, s) | |||
| 186 | + | } | |||
| 187 | + | d.mu.Unlock() | |||
| 188 | + | ||||
| 189 | + | delivered := false | |||
| 190 | + | for _, s := range sessions { | |||
| 191 | + | s.mu.Lock() | |||
| 192 | + | ac := s.client | |||
| 193 | + | s.mu.Unlock() | |||
| 194 | + | if ac != nil { | |||
| 195 | + | d.showToast(ac, n) | |||
| 196 | + | delivered = true | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | if !delivered { | |||
| 200 | + | d.notices.queueGlobal(n) | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // NotifyGlobal raises a daemon-wide notice from outside the daemon package. | |||
| 205 | + | func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) { | |||
| 206 | + | d.notify(nil, sev, code, msg, cause) | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // showToast publishes a notice into one client's toast stack. An identical | |||
| 210 | + | // code within the same scope coalesces into the existing entry and restarts its | |||
| 211 | + | // TTL; otherwise the notice is prepended and the stack trimmed. The repaint is | |||
| 212 | + | // issued after noticeMu is released. | |||
| 213 | + | func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) { | |||
| 214 | + | if ac == nil { | |||
| 215 | + | return | |||
| 216 | + | } | |||
| 217 | + | ac.initOverlays() | |||
| 218 | + | rt := ac.overlays | |||
| 219 | + | ||||
| 220 | + | rt.noticeMu.Lock() | |||
| 221 | + | if i := rt.indexOfToastLocked(n.Code, n.SessionID); i >= 0 { | |||
| 222 | + | rt.noticeToasts[i].n.Count += n.Count | |||
| 223 | + | rt.noticeToasts[i].n.Time = n.Time | |||
| 224 | + | rt.noticeToasts[i].n.Details = n.Details | |||
| 225 | + | rt.noticeToasts[i].timer.stop() | |||
| 226 | + | d.retainToastTimerLocked(ac, &rt.noticeToasts[i]) | |||
| 227 | + | rt.noticeMu.Unlock() | |||
| 228 | + | d.repaintForNotice(ac) | |||
| 229 | + | return | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | rt.noticeSeq++ | |||
| 233 | + | toast := noticeToast{n: n, seq: rt.noticeSeq} | |||
| 234 | + | rt.noticeToasts = append([]noticeToast{toast}, rt.noticeToasts...) | |||
| 235 | + | if len(rt.noticeToasts) > maxVisibleToasts { | |||
| 236 | + | for i := maxVisibleToasts; i < len(rt.noticeToasts); i++ { | |||
| 237 | + | rt.noticeToasts[i].timer.stop() | |||
| 238 | + | rt.noticeOverflow++ | |||
| 239 | + | } | |||
| 240 | + | rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts] | |||
| 241 | + | } | |||
| 242 | + | d.retainToastTimerLocked(ac, &rt.noticeToasts[0]) | |||
| 243 | + | rt.noticeMu.Unlock() | |||
| 244 | + | d.repaintForNotice(ac) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // indexOfToastLocked finds a visible toast with the same code and scope. | |||
| 248 | + | // Callers must hold noticeMu. | |||
| 249 | + | func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain.SessionID) int { | |||
| 250 | + | for i := range rt.noticeToasts { | |||
| 251 | + | if rt.noticeToasts[i].n.Code == code && rt.noticeToasts[i].n.SessionID == sid { | |||
| 252 | + | return i | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | return -1 | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the | |||
| 259 | + | // timer goroutine re-acquires it and releases it before repainting, so the | |||
| 260 | + | // callback never runs with noticeMu held. | |||
| 261 | + | func (d *Daemon) retainToastTimerLocked(ac *attachedClient, t *noticeToast) { | |||
| 262 | + | rt := ac.overlays | |||
| 263 | + | rt.noticeSeq++ | |||
| 264 | + | t.seq = rt.noticeSeq | |||
| 265 | + | seq := t.seq | |||
| 266 | + | t.timer.retain(d.clock, noticeTTL(t.n.Severity), func(ports.Timer) { | |||
| 267 | + | rt.noticeMu.Lock() | |||
| 268 | + | kept := rt.noticeToasts[:0] | |||
| 269 | + | for _, tt := range rt.noticeToasts { | |||
| 270 | + | if tt.seq == seq { | |||
| 271 | + | continue | |||
| 272 | + | } | |||
| 273 | + | kept = append(kept, tt) | |||
| 274 | + | } | |||
| 275 | + | rt.noticeToasts = kept | |||
| 276 | + | if len(rt.noticeToasts) == 0 { | |||
| 277 | + | rt.noticeOverflow = 0 | |||
| 278 | + | } | |||
| 279 | + | rt.noticeMu.Unlock() | |||
| 280 | + | d.repaintForNotice(ac) | |||
| 281 | + | }) | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // repaintForNotice asks for an urgent redraw. It must be called with noticeMu | |||
| 285 | + | // released: invalidateRender can paint inline, which takes sendMu. | |||
| 286 | + | func (d *Daemon) repaintForNotice(ac *attachedClient) { | |||
| 287 | + | sess := ac.currentSession() | |||
| 288 | + | if sess == nil { | |||
| 289 | + | // Mid-handoff or torn down: whoever attaches next repaints anyway, and | |||
| 290 | + | // invalidateRender cannot paint without a session. | |||
| 291 | + | return | |||
| 292 | + | } | |||
| 293 | + | d.invalidateRender(sess, ac, true, "notify.go") | |||
| 294 | + | } | |||
M internal/usecase/daemon/notify_test.go +464 -0
| @@ -1,11 +1,18 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 4 | 6 | "fmt" | |
| 7 | + | "sync" | |
| 8 | + | "sync/atomic" | |
| 5 | 9 | "testing" | |
| 6 | 10 | "time" | |
| 7 | 11 | ||
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 8 | 14 | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | 16 | ) | |
| 10 | 17 | ||
| 11 | 18 | func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification { | |
| @@ -109,3 +116,460 @@ func TestNoticeCenterPendingQueue(t *testing.T) { | |||
| 109 | 116 | } | |
| 110 | 117 | }) | |
| 111 | 118 | } | |
| 119 | + | ||
| 120 | + | // --- Task 3: routing, per-client toast state, attach drain ------------------ | |
| 121 | + | ||
| 122 | + | // noticeClock hands out timers the test fires explicitly by advancing time. | |
| 123 | + | // Toast TTLs are asserted from the durations recorded here, so the tests never | |
| 124 | + | // depend on wall-clock progress. | |
| 125 | + | type noticeClock struct { | |
| 126 | + | mu sync.Mutex | |
| 127 | + | now time.Time | |
| 128 | + | timers []*noticeTimer | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | type noticeTimer struct { | |
| 132 | + | ch chan time.Time | |
| 133 | + | duration time.Duration | |
| 134 | + | deadline time.Time | |
| 135 | + | stopped atomic.Bool | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func newNoticeClock() *noticeClock { | |
| 139 | + | return ¬iceClock{now: time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)} | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (c *noticeClock) Now() time.Time { | |
| 143 | + | c.mu.Lock() | |
| 144 | + | defer c.mu.Unlock() | |
| 145 | + | return c.now | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (c *noticeClock) NewTimer(d time.Duration) ports.Timer { | |
| 149 | + | c.mu.Lock() | |
| 150 | + | defer c.mu.Unlock() | |
| 151 | + | t := ¬iceTimer{ch: make(chan time.Time, 1), duration: d, deadline: c.now.Add(d)} | |
| 152 | + | c.timers = append(c.timers, t) | |
| 153 | + | return t | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (t *noticeTimer) C() <-chan time.Time { return t.ch } | |
| 157 | + | ||
| 158 | + | func (t *noticeTimer) Reset(time.Duration) bool { return false } | |
| 159 | + | ||
| 160 | + | func (t *noticeTimer) Stop() bool { | |
| 161 | + | t.stopped.Store(true) | |
| 162 | + | return true | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // advance moves the clock forward and fires every live timer that came due. | |
| 166 | + | // Sends are non-blocking on a buffered channel, so no timer goroutine can wedge | |
| 167 | + | // the test goroutine. | |
| 168 | + | func (c *noticeClock) advance(d time.Duration) { | |
| 169 | + | c.mu.Lock() | |
| 170 | + | c.now = c.now.Add(d) | |
| 171 | + | due := make([]*noticeTimer, 0, len(c.timers)) | |
| 172 | + | kept := make([]*noticeTimer, 0, len(c.timers)) | |
| 173 | + | for _, t := range c.timers { | |
| 174 | + | if !t.stopped.Load() && !t.deadline.After(c.now) { | |
| 175 | + | t.stopped.Store(true) | |
| 176 | + | due = append(due, t) | |
| 177 | + | continue | |
| 178 | + | } | |
| 179 | + | kept = append(kept, t) | |
| 180 | + | } | |
| 181 | + | c.timers = kept | |
| 182 | + | now := c.now | |
| 183 | + | c.mu.Unlock() | |
| 184 | + | for _, t := range due { | |
| 185 | + | select { | |
| 186 | + | case t.ch <- now: | |
| 187 | + | default: | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // durations returns every TTL requested so far, in creation order. | |
| 193 | + | func (c *noticeClock) durations() []time.Duration { | |
| 194 | + | c.mu.Lock() | |
| 195 | + | defer c.mu.Unlock() | |
| 196 | + | out := make([]time.Duration, 0, len(c.timers)) | |
| 197 | + | for _, t := range c.timers { | |
| 198 | + | out = append(out, t.duration) | |
| 199 | + | } | |
| 200 | + | return out | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // newNoticeFixture builds a manual session owned by one attached client, with a | |
| 204 | + | // caller-supplied clock so toast TTLs are deterministic. The session has no | |
| 205 | + | // render coordinator, so invalidateRender paints directly and every repaint is | |
| 206 | + | // observable as a frame on the returned channel. | |
| 207 | + | func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attachedClient, chan ports.Frame) { | |
| 208 | + | t.Helper() | |
| 209 | + | p, _ := newBlockingPTY(t) | |
| 210 | + | d := newTestDaemon(t, nil, clk) | |
| 211 | + | tr, sends := newCapturingTransport(t) | |
| 212 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 213 | + | ac.initOverlays() | |
| 214 | + | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 215 | + | wctx, wcancel := context.WithCancel(sctx) | |
| 216 | + | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) | |
| 217 | + | tb.ctx, tb.cancel = wctx, wcancel | |
| 218 | + | for _, pane := range tb.panes { | |
| 219 | + | pane.ctx, pane.cancel = wctx, wcancel | |
| 220 | + | } | |
| 221 | + | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: []*tab{tb}, client: ac} | |
| 222 | + | ac.setSession(sess) | |
| 223 | + | d.sessions[sess.id] = sess | |
| 224 | + | t.Cleanup(cancel) | |
| 225 | + | return d, sess, ac, sends | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // visibleToasts copies the client's toast state under noticeMu. | |
| 229 | + | func visibleToasts(ac *attachedClient) ([]domain.Notification, int) { | |
| 230 | + | rt := ac.overlays | |
| 231 | + | rt.noticeMu.Lock() | |
| 232 | + | defer rt.noticeMu.Unlock() | |
| 233 | + | out := make([]domain.Notification, 0, len(rt.noticeToasts)) | |
| 234 | + | for _, toast := range rt.noticeToasts { | |
| 235 | + | out = append(out, toast.n) | |
| 236 | + | } | |
| 237 | + | return out, rt.noticeOverflow | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // awaitToastCount waits for the toast slice to reach want. The polled condition | |
| 241 | + | // only reads state; assertions stay on the test goroutine. | |
| 242 | + | func awaitToastCount(t *testing.T, ac *attachedClient, want int) []domain.Notification { | |
| 243 | + | t.Helper() | |
| 244 | + | require.Eventually(t, func() bool { | |
| 245 | + | ns, _ := visibleToasts(ac) | |
| 246 | + | return len(ns) == want | |
| 247 | + | }, 2*time.Second, time.Millisecond, "toast count never reached %d", want) | |
| 248 | + | got, _ := visibleToasts(ac) | |
| 249 | + | return got | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func drainFrames(sends chan ports.Frame) { | |
| 253 | + | for { | |
| 254 | + | select { | |
| 255 | + | case <-sends: | |
| 256 | + | default: | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func TestReportErrorUserErrorBecomesNotification(t *testing.T) { | |
| 263 | + | tests := []struct { | |
| 264 | + | name string | |
| 265 | + | err *domain.UserError | |
| 266 | + | wantSev domain.NoticeSeverity | |
| 267 | + | }{ | |
| 268 | + | { | |
| 269 | + | name: "error severity", | |
| 270 | + | err: domain.UserErr(domain.NoticePaneSpawn, "could not open pane", errors.New("fork/exec: no such file")), | |
| 271 | + | wantSev: domain.NoticeError, | |
| 272 | + | }, | |
| 273 | + | { | |
| 274 | + | name: "warn severity", | |
| 275 | + | err: domain.UserWarn(domain.NoticeConfigReload, "config reload skipped", errors.New("parse: line 4")), | |
| 276 | + | wantSev: domain.NoticeWarn, | |
| 277 | + | }, | |
| 278 | + | } | |
| 279 | + | for _, tc := range tests { | |
| 280 | + | t.Run(tc.name, func(t *testing.T) { | |
| 281 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 282 | + | ||
| 283 | + | d.reportError(sess, tc.err) | |
| 284 | + | ||
| 285 | + | hist := d.notices.history() | |
| 286 | + | require.Len(t, hist, 1) | |
| 287 | + | require.Equal(t, tc.err.Code, hist[0].Code) | |
| 288 | + | require.Equal(t, tc.wantSev, hist[0].Severity) | |
| 289 | + | require.Equal(t, tc.err.Msg, hist[0].Message) | |
| 290 | + | require.Equal(t, sess.id, hist[0].SessionID) | |
| 291 | + | require.Equal(t, 1, hist[0].Count) | |
| 292 | + | // Details carry the cause chain; the message never does. | |
| 293 | + | require.Contains(t, hist[0].Details, tc.err.Err.Error()) | |
| 294 | + | require.NotContains(t, hist[0].Message, tc.err.Err.Error()) | |
| 295 | + | ||
| 296 | + | toasts := awaitToastCount(t, ac, 1) | |
| 297 | + | require.Equal(t, tc.err.Msg, toasts[0].Message) | |
| 298 | + | require.Equal(t, tc.err.Code, toasts[0].Code) | |
| 299 | + | }) | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func TestReportErrorDetailsJoinUnwrapChain(t *testing.T) { | |
| 304 | + | d, sess, _, _ := newNoticeFixture(t, newNoticeClock()) | |
| 305 | + | root := errors.New("permission denied") | |
| 306 | + | mid := fmt.Errorf("open /dev/pts/3: %w", root) | |
| 307 | + | ||
| 308 | + | d.reportError(sess, domain.UserErr(domain.NoticePaneSpawn, "could not open pane", mid)) | |
| 309 | + | ||
| 310 | + | hist := d.notices.history() | |
| 311 | + | require.Len(t, hist, 1) | |
| 312 | + | require.Equal(t, mid.Error()+" ← "+root.Error(), hist[0].Details) | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func TestReportErrorUnknownErrorBecomesInternal(t *testing.T) { | |
| 316 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 317 | + | raw := errors.New("something unclassified broke") | |
| 318 | + | ||
| 319 | + | d.reportError(sess, raw) | |
| 320 | + | ||
| 321 | + | hist := d.notices.history() | |
| 322 | + | require.Len(t, hist, 1) | |
| 323 | + | require.Equal(t, domain.NoticeInternal, hist[0].Code) | |
| 324 | + | require.Equal(t, domain.NoticeError, hist[0].Severity) | |
| 325 | + | require.Equal(t, "internal error", hist[0].Message) | |
| 326 | + | require.Equal(t, raw.Error(), hist[0].Details) | |
| 327 | + | ||
| 328 | + | toasts := awaitToastCount(t, ac, 1) | |
| 329 | + | require.Equal(t, "internal error", toasts[0].Message) | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func TestReportErrorBenignFiltered(t *testing.T) { | |
| 333 | + | tests := []struct { | |
| 334 | + | name string | |
| 335 | + | err error | |
| 336 | + | }{ | |
| 337 | + | {name: "nil", err: nil}, | |
| 338 | + | {name: "context canceled", err: context.Canceled}, | |
| 339 | + | {name: "wrapped context canceled", err: fmt.Errorf("pane read: %w", context.Canceled)}, | |
| 340 | + | {name: "no neighbor", err: errNoNeighbor}, | |
| 341 | + | {name: "wrapped no neighbor", err: fmt.Errorf("focus: %w", errNoNeighbor)}, | |
| 342 | + | {name: "no clipboard image", err: ports.ErrNoClipboardImage}, | |
| 343 | + | {name: "wrapped no clipboard image", err: fmt.Errorf("yank: %w", ports.ErrNoClipboardImage)}, | |
| 344 | + | } | |
| 345 | + | for _, tc := range tests { | |
| 346 | + | t.Run(tc.name, func(t *testing.T) { | |
| 347 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 348 | + | ||
| 349 | + | d.reportError(sess, tc.err) | |
| 350 | + | ||
| 351 | + | require.Empty(t, d.notices.history()) | |
| 352 | + | toasts, _ := visibleToasts(ac) | |
| 353 | + | require.Empty(t, toasts) | |
| 354 | + | }) | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | // TestReportErrorNonBenignIsNeverDropped guards the inverse of the filter: an | |
| 359 | + | // error that merely reads like a sentinel must still reach history. | |
| 360 | + | func TestReportErrorNonBenignIsNeverDropped(t *testing.T) { | |
| 361 | + | d, sess, _, _ := newNoticeFixture(t, newNoticeClock()) | |
| 362 | + | ||
| 363 | + | d.reportError(sess, errors.New("no pane in that direction")) | |
| 364 | + | ||
| 365 | + | require.Len(t, d.notices.history(), 1, "a look-alike error must not be filtered by message text") | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | func TestNotifyRoutesToSessionClientOnly(t *testing.T) { | |
| 369 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 370 | + | _, _, otherAC, _ := newNoticeFixture(t, newNoticeClock()) | |
| 371 | + | ||
| 372 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil) | |
| 373 | + | ||
| 374 | + | toasts := awaitToastCount(t, ac, 1) | |
| 375 | + | require.Equal(t, sess.id, toasts[0].SessionID) | |
| 376 | + | otherToasts, _ := visibleToasts(otherAC) | |
| 377 | + | require.Empty(t, otherToasts, "another daemon's client must be untouched") | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | func TestNotifySessionWithoutClientRecordsHistoryOnly(t *testing.T) { | |
| 381 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 382 | + | sess.mu.Lock() | |
| 383 | + | sess.client = nil | |
| 384 | + | sess.mu.Unlock() | |
| 385 | + | ||
| 386 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |
| 387 | + | ||
| 388 | + | require.Len(t, d.notices.history(), 1) | |
| 389 | + | toasts, _ := visibleToasts(ac) | |
| 390 | + | require.Empty(t, toasts, "a detached session must not paint a toast") | |
| 391 | + | require.Empty(t, d.notices.drainPending(), "session-scoped notices are never queued as pending globals") | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) { | |
| 395 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 396 | + | ||
| 397 | + | second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 398 | + | second.initOverlays() | |
| 399 | + | sess2 := &session{id: "manual-2", name: "other", ctx: sess.ctx, cancel: func() {}, client: second} | |
| 400 | + | second.setSession(sess2) | |
| 401 | + | d.mu.Lock() | |
| 402 | + | d.sessions[sess2.id] = sess2 | |
| 403 | + | d.mu.Unlock() | |
| 404 | + | ||
| 405 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil) | |
| 406 | + | ||
| 407 | + | first := awaitToastCount(t, ac, 1) | |
| 408 | + | require.Equal(t, domain.SessionID(""), first[0].SessionID, "global notices carry an empty session id") | |
| 409 | + | secondToasts := awaitToastCount(t, second, 1) | |
| 410 | + | require.Equal(t, domain.NoticeConfigReload, secondToasts[0].Code) | |
| 411 | + | require.Empty(t, d.notices.drainPending(), "delivered globals must not also be queued") | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |
| 415 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 416 | + | sess.mu.Lock() | |
| 417 | + | sess.client = nil | |
| 418 | + | sess.mu.Unlock() | |
| 419 | + | ||
| 420 | + | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil) | |
| 421 | + | ||
| 422 | + | require.Len(t, d.notices.history(), 1) | |
| 423 | + | toasts, _ := visibleToasts(ac) | |
| 424 | + | require.Empty(t, toasts, "no attached client means nothing to paint yet") | |
| 425 | + | ||
| 426 | + | // Re-attach and let firstPaint drain the pending queue. | |
| 427 | + | sess.mu.Lock() | |
| 428 | + | sess.client = ac | |
| 429 | + | sess.mu.Unlock() | |
| 430 | + | d.firstPaint(sess, ac, domain.Size{}) | |
| 431 | + | ||
| 432 | + | drained := awaitToastCount(t, ac, 1) | |
| 433 | + | require.Equal(t, domain.NoticeSnapshotWrite, drained[0].Code) | |
| 434 | + | require.Equal(t, "snapshot write failed", drained[0].Message) | |
| 435 | + | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | func TestShowToastCoalesceAndTrim(t *testing.T) { | |
| 439 | + | t.Run("same code and scope coalesces", func(t *testing.T) { | |
| 440 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 441 | + | ||
| 442 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |
| 443 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |
| 444 | + | ||
| 445 | + | toasts := awaitToastCount(t, ac, 1) | |
| 446 | + | require.Equal(t, 2, toasts[0].Count) | |
| 447 | + | _, overflow := visibleToasts(ac) | |
| 448 | + | require.Zero(t, overflow) | |
| 449 | + | require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both") | |
| 450 | + | }) | |
| 451 | + | ||
| 452 | + | t.Run("same code different scope does not coalesce", func(t *testing.T) { | |
| 453 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 454 | + | ||
| 455 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |
| 456 | + | d.notify(nil, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |
| 457 | + | ||
| 458 | + | toasts := awaitToastCount(t, ac, 2) | |
| 459 | + | require.Equal(t, 1, toasts[0].Count) | |
| 460 | + | require.Equal(t, 1, toasts[1].Count) | |
| 461 | + | }) | |
| 462 | + | ||
| 463 | + | t.Run("four distinct codes trim to three newest first", func(t *testing.T) { | |
| 464 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 465 | + | codes := []domain.NoticeCode{ | |
| 466 | + | domain.NoticePaneSpawn, | |
| 467 | + | domain.NoticeTabSpawn, | |
| 468 | + | domain.NoticeSnapshotWrite, | |
| 469 | + | domain.NoticeClipboard, | |
| 470 | + | } | |
| 471 | + | for _, c := range codes { | |
| 472 | + | d.notify(sess, domain.NoticeError, c, c.String(), nil) | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | toasts := awaitToastCount(t, ac, maxVisibleToasts) | |
| 476 | + | require.Equal(t, []domain.NoticeCode{codes[3], codes[2], codes[1]}, []domain.NoticeCode{ | |
| 477 | + | toasts[0].Code, toasts[1].Code, toasts[2].Code, | |
| 478 | + | }, "newest first, oldest evicted") | |
| 479 | + | _, overflow := visibleToasts(ac) | |
| 480 | + | require.Equal(t, 1, overflow) | |
| 481 | + | }) | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | func TestNoticeTTLPerSeverity(t *testing.T) { | |
| 485 | + | tests := []struct { | |
| 486 | + | name string | |
| 487 | + | sev domain.NoticeSeverity | |
| 488 | + | want time.Duration | |
| 489 | + | }{ | |
| 490 | + | {name: "info", sev: domain.NoticeInfo, want: 4 * time.Second}, | |
| 491 | + | {name: "warn", sev: domain.NoticeWarn, want: 6 * time.Second}, | |
| 492 | + | {name: "error", sev: domain.NoticeError, want: 8 * time.Second}, | |
| 493 | + | } | |
| 494 | + | for _, tc := range tests { | |
| 495 | + | t.Run(tc.name, func(t *testing.T) { | |
| 496 | + | require.Equal(t, tc.want, noticeTTL(tc.sev)) | |
| 497 | + | ||
| 498 | + | clk := newNoticeClock() | |
| 499 | + | d, sess, ac, _ := newNoticeFixture(t, clk) | |
| 500 | + | d.notify(sess, tc.sev, domain.NoticeClipboard, "msg", nil) | |
| 501 | + | awaitToastCount(t, ac, 1) | |
| 502 | + | require.Equal(t, []time.Duration{tc.want}, clk.durations(), "the retained toast timer uses the severity TTL") | |
| 503 | + | }) | |
| 504 | + | } | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | func TestToastExpiresOnFakeClock(t *testing.T) { | |
| 508 | + | clk := newNoticeClock() | |
| 509 | + | d, sess, ac, sends := newNoticeFixture(t, clk) | |
| 510 | + | ||
| 511 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |
| 512 | + | awaitToastCount(t, ac, 1) | |
| 513 | + | drainFrames(sends) | |
| 514 | + | ||
| 515 | + | // Short of the 8s error TTL nothing expires. | |
| 516 | + | clk.advance(7 * time.Second) | |
| 517 | + | toasts, _ := visibleToasts(ac) | |
| 518 | + | require.Len(t, toasts, 1, "toast must survive until its TTL elapses") | |
| 519 | + | ||
| 520 | + | clk.advance(2 * time.Second) | |
| 521 | + | awaitToastCount(t, ac, 0) | |
| 522 | + | _, overflow := visibleToasts(ac) | |
| 523 | + | require.Zero(t, overflow, "overflow resets once no toast is visible") | |
| 524 | + | ||
| 525 | + | select { | |
| 526 | + | case <-sends: | |
| 527 | + | case <-time.After(2 * time.Second): | |
| 528 | + | t.Fatal("expiry did not invalidate the render") | |
| 529 | + | } | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | // TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a | |
| 533 | + | // toast that outlived it. | |
| 534 | + | func TestToastExpiryOnlyRemovesItsOwnEntry(t *testing.T) { | |
| 535 | + | clk := newNoticeClock() | |
| 536 | + | d, sess, ac, _ := newNoticeFixture(t, clk) | |
| 537 | + | ||
| 538 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil) // 4s TTL | |
| 539 | + | awaitToastCount(t, ac, 1) | |
| 540 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "boom", nil) // 8s TTL | |
| 541 | + | awaitToastCount(t, ac, 2) | |
| 542 | + | ||
| 543 | + | clk.advance(5 * time.Second) | |
| 544 | + | ||
| 545 | + | toasts := awaitToastCount(t, ac, 1) | |
| 546 | + | require.Equal(t, domain.NoticePaneSpawn, toasts[0].Code, "only the expired info toast is removed") | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | // TestToastExpiryRemovesOnlyTheEntryItArmed pins the removal predicate: an | |
| 550 | + | // expiry timer identifies its toast by entry sequence, not by code and scope. | |
| 551 | + | // Coalescing re-arms the entry under a fresh sequence, so a timer that was | |
| 552 | + | // stopped but had already slipped past its select cannot dismiss the refreshed | |
| 553 | + | // toast that replaced the one it belonged to. | |
| 554 | + | func TestToastExpiryRemovesOnlyTheEntryItArmed(t *testing.T) { | |
| 555 | + | clk := newNoticeClock() | |
| 556 | + | d, sess, ac, _ := newNoticeFixture(t, clk) | |
| 557 | + | ||
| 558 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil) | |
| 559 | + | awaitToastCount(t, ac, 1) | |
| 560 | + | ||
| 561 | + | // Re-stamp the live entry's sequence, exactly as coalescing does, without | |
| 562 | + | // disturbing the already-armed timer. Its code and scope are unchanged. | |
| 563 | + | rt := ac.overlays | |
| 564 | + | rt.noticeMu.Lock() | |
| 565 | + | rt.noticeSeq++ | |
| 566 | + | rt.noticeToasts[0].seq = rt.noticeSeq | |
| 567 | + | rt.noticeMu.Unlock() | |
| 568 | + | ||
| 569 | + | clk.advance(4 * time.Second) | |
| 570 | + | ||
| 571 | + | require.Never(t, func() bool { | |
| 572 | + | ns, _ := visibleToasts(ac) | |
| 573 | + | return len(ns) == 0 | |
| 574 | + | }, 200*time.Millisecond, 5*time.Millisecond, "expiry must remove only the entry it armed") | |
| 575 | + | } | |
| @@ -1,11 +1,18 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 4 | "fmt" | 6 | "fmt" | ||
| 7 | + | "sync" | |||
| 8 | + | "sync/atomic" | |||
| 5 | "testing" | 9 | "testing" | ||
| 6 | "time" | 10 | "time" | ||
| 7 | 11 | ||||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 14 | "github.com/bnema/vev/internal/domain" | ||
| 15 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | ) | 16 | ) | ||
| 10 | 17 | ||||
| 11 | func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification { | 18 | func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification { | ||
| @@ -109,3 +116,460 @@ func TestNoticeCenterPendingQueue(t *testing.T) { | |||||
| 109 | } | 116 | } | ||
| 110 | }) | 117 | }) | ||
| 111 | } | 118 | } | ||
| 119 | + | ||||
| 120 | + | // --- Task 3: routing, per-client toast state, attach drain ------------------ | |||
| 121 | + | ||||
| 122 | + | // noticeClock hands out timers the test fires explicitly by advancing time. | |||
| 123 | + | // Toast TTLs are asserted from the durations recorded here, so the tests never | |||
| 124 | + | // depend on wall-clock progress. | |||
| 125 | + | type noticeClock struct { | |||
| 126 | + | mu sync.Mutex | |||
| 127 | + | now time.Time | |||
| 128 | + | timers []*noticeTimer | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | type noticeTimer struct { | |||
| 132 | + | ch chan time.Time | |||
| 133 | + | duration time.Duration | |||
| 134 | + | deadline time.Time | |||
| 135 | + | stopped atomic.Bool | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func newNoticeClock() *noticeClock { | |||
| 139 | + | return ¬iceClock{now: time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)} | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func (c *noticeClock) Now() time.Time { | |||
| 143 | + | c.mu.Lock() | |||
| 144 | + | defer c.mu.Unlock() | |||
| 145 | + | return c.now | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func (c *noticeClock) NewTimer(d time.Duration) ports.Timer { | |||
| 149 | + | c.mu.Lock() | |||
| 150 | + | defer c.mu.Unlock() | |||
| 151 | + | t := ¬iceTimer{ch: make(chan time.Time, 1), duration: d, deadline: c.now.Add(d)} | |||
| 152 | + | c.timers = append(c.timers, t) | |||
| 153 | + | return t | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func (t *noticeTimer) C() <-chan time.Time { return t.ch } | |||
| 157 | + | ||||
| 158 | + | func (t *noticeTimer) Reset(time.Duration) bool { return false } | |||
| 159 | + | ||||
| 160 | + | func (t *noticeTimer) Stop() bool { | |||
| 161 | + | t.stopped.Store(true) | |||
| 162 | + | return true | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // advance moves the clock forward and fires every live timer that came due. | |||
| 166 | + | // Sends are non-blocking on a buffered channel, so no timer goroutine can wedge | |||
| 167 | + | // the test goroutine. | |||
| 168 | + | func (c *noticeClock) advance(d time.Duration) { | |||
| 169 | + | c.mu.Lock() | |||
| 170 | + | c.now = c.now.Add(d) | |||
| 171 | + | due := make([]*noticeTimer, 0, len(c.timers)) | |||
| 172 | + | kept := make([]*noticeTimer, 0, len(c.timers)) | |||
| 173 | + | for _, t := range c.timers { | |||
| 174 | + | if !t.stopped.Load() && !t.deadline.After(c.now) { | |||
| 175 | + | t.stopped.Store(true) | |||
| 176 | + | due = append(due, t) | |||
| 177 | + | continue | |||
| 178 | + | } | |||
| 179 | + | kept = append(kept, t) | |||
| 180 | + | } | |||
| 181 | + | c.timers = kept | |||
| 182 | + | now := c.now | |||
| 183 | + | c.mu.Unlock() | |||
| 184 | + | for _, t := range due { | |||
| 185 | + | select { | |||
| 186 | + | case t.ch <- now: | |||
| 187 | + | default: | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // durations returns every TTL requested so far, in creation order. | |||
| 193 | + | func (c *noticeClock) durations() []time.Duration { | |||
| 194 | + | c.mu.Lock() | |||
| 195 | + | defer c.mu.Unlock() | |||
| 196 | + | out := make([]time.Duration, 0, len(c.timers)) | |||
| 197 | + | for _, t := range c.timers { | |||
| 198 | + | out = append(out, t.duration) | |||
| 199 | + | } | |||
| 200 | + | return out | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | // newNoticeFixture builds a manual session owned by one attached client, with a | |||
| 204 | + | // caller-supplied clock so toast TTLs are deterministic. The session has no | |||
| 205 | + | // render coordinator, so invalidateRender paints directly and every repaint is | |||
| 206 | + | // observable as a frame on the returned channel. | |||
| 207 | + | func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attachedClient, chan ports.Frame) { | |||
| 208 | + | t.Helper() | |||
| 209 | + | p, _ := newBlockingPTY(t) | |||
| 210 | + | d := newTestDaemon(t, nil, clk) | |||
| 211 | + | tr, sends := newCapturingTransport(t) | |||
| 212 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 213 | + | ac.initOverlays() | |||
| 214 | + | sctx, cancel := context.WithCancel(d.serveCtx) | |||
| 215 | + | wctx, wcancel := context.WithCancel(sctx) | |||
| 216 | + | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) | |||
| 217 | + | tb.ctx, tb.cancel = wctx, wcancel | |||
| 218 | + | for _, pane := range tb.panes { | |||
| 219 | + | pane.ctx, pane.cancel = wctx, wcancel | |||
| 220 | + | } | |||
| 221 | + | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: []*tab{tb}, client: ac} | |||
| 222 | + | ac.setSession(sess) | |||
| 223 | + | d.sessions[sess.id] = sess | |||
| 224 | + | t.Cleanup(cancel) | |||
| 225 | + | return d, sess, ac, sends | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | // visibleToasts copies the client's toast state under noticeMu. | |||
| 229 | + | func visibleToasts(ac *attachedClient) ([]domain.Notification, int) { | |||
| 230 | + | rt := ac.overlays | |||
| 231 | + | rt.noticeMu.Lock() | |||
| 232 | + | defer rt.noticeMu.Unlock() | |||
| 233 | + | out := make([]domain.Notification, 0, len(rt.noticeToasts)) | |||
| 234 | + | for _, toast := range rt.noticeToasts { | |||
| 235 | + | out = append(out, toast.n) | |||
| 236 | + | } | |||
| 237 | + | return out, rt.noticeOverflow | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // awaitToastCount waits for the toast slice to reach want. The polled condition | |||
| 241 | + | // only reads state; assertions stay on the test goroutine. | |||
| 242 | + | func awaitToastCount(t *testing.T, ac *attachedClient, want int) []domain.Notification { | |||
| 243 | + | t.Helper() | |||
| 244 | + | require.Eventually(t, func() bool { | |||
| 245 | + | ns, _ := visibleToasts(ac) | |||
| 246 | + | return len(ns) == want | |||
| 247 | + | }, 2*time.Second, time.Millisecond, "toast count never reached %d", want) | |||
| 248 | + | got, _ := visibleToasts(ac) | |||
| 249 | + | return got | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func drainFrames(sends chan ports.Frame) { | |||
| 253 | + | for { | |||
| 254 | + | select { | |||
| 255 | + | case <-sends: | |||
| 256 | + | default: | |||
| 257 | + | return | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func TestReportErrorUserErrorBecomesNotification(t *testing.T) { | |||
| 263 | + | tests := []struct { | |||
| 264 | + | name string | |||
| 265 | + | err *domain.UserError | |||
| 266 | + | wantSev domain.NoticeSeverity | |||
| 267 | + | }{ | |||
| 268 | + | { | |||
| 269 | + | name: "error severity", | |||
| 270 | + | err: domain.UserErr(domain.NoticePaneSpawn, "could not open pane", errors.New("fork/exec: no such file")), | |||
| 271 | + | wantSev: domain.NoticeError, | |||
| 272 | + | }, | |||
| 273 | + | { | |||
| 274 | + | name: "warn severity", | |||
| 275 | + | err: domain.UserWarn(domain.NoticeConfigReload, "config reload skipped", errors.New("parse: line 4")), | |||
| 276 | + | wantSev: domain.NoticeWarn, | |||
| 277 | + | }, | |||
| 278 | + | } | |||
| 279 | + | for _, tc := range tests { | |||
| 280 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 281 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 282 | + | ||||
| 283 | + | d.reportError(sess, tc.err) | |||
| 284 | + | ||||
| 285 | + | hist := d.notices.history() | |||
| 286 | + | require.Len(t, hist, 1) | |||
| 287 | + | require.Equal(t, tc.err.Code, hist[0].Code) | |||
| 288 | + | require.Equal(t, tc.wantSev, hist[0].Severity) | |||
| 289 | + | require.Equal(t, tc.err.Msg, hist[0].Message) | |||
| 290 | + | require.Equal(t, sess.id, hist[0].SessionID) | |||
| 291 | + | require.Equal(t, 1, hist[0].Count) | |||
| 292 | + | // Details carry the cause chain; the message never does. | |||
| 293 | + | require.Contains(t, hist[0].Details, tc.err.Err.Error()) | |||
| 294 | + | require.NotContains(t, hist[0].Message, tc.err.Err.Error()) | |||
| 295 | + | ||||
| 296 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 297 | + | require.Equal(t, tc.err.Msg, toasts[0].Message) | |||
| 298 | + | require.Equal(t, tc.err.Code, toasts[0].Code) | |||
| 299 | + | }) | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | func TestReportErrorDetailsJoinUnwrapChain(t *testing.T) { | |||
| 304 | + | d, sess, _, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 305 | + | root := errors.New("permission denied") | |||
| 306 | + | mid := fmt.Errorf("open /dev/pts/3: %w", root) | |||
| 307 | + | ||||
| 308 | + | d.reportError(sess, domain.UserErr(domain.NoticePaneSpawn, "could not open pane", mid)) | |||
| 309 | + | ||||
| 310 | + | hist := d.notices.history() | |||
| 311 | + | require.Len(t, hist, 1) | |||
| 312 | + | require.Equal(t, mid.Error()+" ← "+root.Error(), hist[0].Details) | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | func TestReportErrorUnknownErrorBecomesInternal(t *testing.T) { | |||
| 316 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 317 | + | raw := errors.New("something unclassified broke") | |||
| 318 | + | ||||
| 319 | + | d.reportError(sess, raw) | |||
| 320 | + | ||||
| 321 | + | hist := d.notices.history() | |||
| 322 | + | require.Len(t, hist, 1) | |||
| 323 | + | require.Equal(t, domain.NoticeInternal, hist[0].Code) | |||
| 324 | + | require.Equal(t, domain.NoticeError, hist[0].Severity) | |||
| 325 | + | require.Equal(t, "internal error", hist[0].Message) | |||
| 326 | + | require.Equal(t, raw.Error(), hist[0].Details) | |||
| 327 | + | ||||
| 328 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 329 | + | require.Equal(t, "internal error", toasts[0].Message) | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | func TestReportErrorBenignFiltered(t *testing.T) { | |||
| 333 | + | tests := []struct { | |||
| 334 | + | name string | |||
| 335 | + | err error | |||
| 336 | + | }{ | |||
| 337 | + | {name: "nil", err: nil}, | |||
| 338 | + | {name: "context canceled", err: context.Canceled}, | |||
| 339 | + | {name: "wrapped context canceled", err: fmt.Errorf("pane read: %w", context.Canceled)}, | |||
| 340 | + | {name: "no neighbor", err: errNoNeighbor}, | |||
| 341 | + | {name: "wrapped no neighbor", err: fmt.Errorf("focus: %w", errNoNeighbor)}, | |||
| 342 | + | {name: "no clipboard image", err: ports.ErrNoClipboardImage}, | |||
| 343 | + | {name: "wrapped no clipboard image", err: fmt.Errorf("yank: %w", ports.ErrNoClipboardImage)}, | |||
| 344 | + | } | |||
| 345 | + | for _, tc := range tests { | |||
| 346 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 347 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 348 | + | ||||
| 349 | + | d.reportError(sess, tc.err) | |||
| 350 | + | ||||
| 351 | + | require.Empty(t, d.notices.history()) | |||
| 352 | + | toasts, _ := visibleToasts(ac) | |||
| 353 | + | require.Empty(t, toasts) | |||
| 354 | + | }) | |||
| 355 | + | } | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | // TestReportErrorNonBenignIsNeverDropped guards the inverse of the filter: an | |||
| 359 | + | // error that merely reads like a sentinel must still reach history. | |||
| 360 | + | func TestReportErrorNonBenignIsNeverDropped(t *testing.T) { | |||
| 361 | + | d, sess, _, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 362 | + | ||||
| 363 | + | d.reportError(sess, errors.New("no pane in that direction")) | |||
| 364 | + | ||||
| 365 | + | require.Len(t, d.notices.history(), 1, "a look-alike error must not be filtered by message text") | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | func TestNotifyRoutesToSessionClientOnly(t *testing.T) { | |||
| 369 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 370 | + | _, _, otherAC, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 371 | + | ||||
| 372 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil) | |||
| 373 | + | ||||
| 374 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 375 | + | require.Equal(t, sess.id, toasts[0].SessionID) | |||
| 376 | + | otherToasts, _ := visibleToasts(otherAC) | |||
| 377 | + | require.Empty(t, otherToasts, "another daemon's client must be untouched") | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | func TestNotifySessionWithoutClientRecordsHistoryOnly(t *testing.T) { | |||
| 381 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 382 | + | sess.mu.Lock() | |||
| 383 | + | sess.client = nil | |||
| 384 | + | sess.mu.Unlock() | |||
| 385 | + | ||||
| 386 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |||
| 387 | + | ||||
| 388 | + | require.Len(t, d.notices.history(), 1) | |||
| 389 | + | toasts, _ := visibleToasts(ac) | |||
| 390 | + | require.Empty(t, toasts, "a detached session must not paint a toast") | |||
| 391 | + | require.Empty(t, d.notices.drainPending(), "session-scoped notices are never queued as pending globals") | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) { | |||
| 395 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 396 | + | ||||
| 397 | + | second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 398 | + | second.initOverlays() | |||
| 399 | + | sess2 := &session{id: "manual-2", name: "other", ctx: sess.ctx, cancel: func() {}, client: second} | |||
| 400 | + | second.setSession(sess2) | |||
| 401 | + | d.mu.Lock() | |||
| 402 | + | d.sessions[sess2.id] = sess2 | |||
| 403 | + | d.mu.Unlock() | |||
| 404 | + | ||||
| 405 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil) | |||
| 406 | + | ||||
| 407 | + | first := awaitToastCount(t, ac, 1) | |||
| 408 | + | require.Equal(t, domain.SessionID(""), first[0].SessionID, "global notices carry an empty session id") | |||
| 409 | + | secondToasts := awaitToastCount(t, second, 1) | |||
| 410 | + | require.Equal(t, domain.NoticeConfigReload, secondToasts[0].Code) | |||
| 411 | + | require.Empty(t, d.notices.drainPending(), "delivered globals must not also be queued") | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |||
| 415 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 416 | + | sess.mu.Lock() | |||
| 417 | + | sess.client = nil | |||
| 418 | + | sess.mu.Unlock() | |||
| 419 | + | ||||
| 420 | + | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil) | |||
| 421 | + | ||||
| 422 | + | require.Len(t, d.notices.history(), 1) | |||
| 423 | + | toasts, _ := visibleToasts(ac) | |||
| 424 | + | require.Empty(t, toasts, "no attached client means nothing to paint yet") | |||
| 425 | + | ||||
| 426 | + | // Re-attach and let firstPaint drain the pending queue. | |||
| 427 | + | sess.mu.Lock() | |||
| 428 | + | sess.client = ac | |||
| 429 | + | sess.mu.Unlock() | |||
| 430 | + | d.firstPaint(sess, ac, domain.Size{}) | |||
| 431 | + | ||||
| 432 | + | drained := awaitToastCount(t, ac, 1) | |||
| 433 | + | require.Equal(t, domain.NoticeSnapshotWrite, drained[0].Code) | |||
| 434 | + | require.Equal(t, "snapshot write failed", drained[0].Message) | |||
| 435 | + | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | func TestShowToastCoalesceAndTrim(t *testing.T) { | |||
| 439 | + | t.Run("same code and scope coalesces", func(t *testing.T) { | |||
| 440 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 441 | + | ||||
| 442 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |||
| 443 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |||
| 444 | + | ||||
| 445 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 446 | + | require.Equal(t, 2, toasts[0].Count) | |||
| 447 | + | _, overflow := visibleToasts(ac) | |||
| 448 | + | require.Zero(t, overflow) | |||
| 449 | + | require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both") | |||
| 450 | + | }) | |||
| 451 | + | ||||
| 452 | + | t.Run("same code different scope does not coalesce", func(t *testing.T) { | |||
| 453 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 454 | + | ||||
| 455 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |||
| 456 | + | d.notify(nil, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |||
| 457 | + | ||||
| 458 | + | toasts := awaitToastCount(t, ac, 2) | |||
| 459 | + | require.Equal(t, 1, toasts[0].Count) | |||
| 460 | + | require.Equal(t, 1, toasts[1].Count) | |||
| 461 | + | }) | |||
| 462 | + | ||||
| 463 | + | t.Run("four distinct codes trim to three newest first", func(t *testing.T) { | |||
| 464 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 465 | + | codes := []domain.NoticeCode{ | |||
| 466 | + | domain.NoticePaneSpawn, | |||
| 467 | + | domain.NoticeTabSpawn, | |||
| 468 | + | domain.NoticeSnapshotWrite, | |||
| 469 | + | domain.NoticeClipboard, | |||
| 470 | + | } | |||
| 471 | + | for _, c := range codes { | |||
| 472 | + | d.notify(sess, domain.NoticeError, c, c.String(), nil) | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | toasts := awaitToastCount(t, ac, maxVisibleToasts) | |||
| 476 | + | require.Equal(t, []domain.NoticeCode{codes[3], codes[2], codes[1]}, []domain.NoticeCode{ | |||
| 477 | + | toasts[0].Code, toasts[1].Code, toasts[2].Code, | |||
| 478 | + | }, "newest first, oldest evicted") | |||
| 479 | + | _, overflow := visibleToasts(ac) | |||
| 480 | + | require.Equal(t, 1, overflow) | |||
| 481 | + | }) | |||
| 482 | + | } | |||
| 483 | + | ||||
| 484 | + | func TestNoticeTTLPerSeverity(t *testing.T) { | |||
| 485 | + | tests := []struct { | |||
| 486 | + | name string | |||
| 487 | + | sev domain.NoticeSeverity | |||
| 488 | + | want time.Duration | |||
| 489 | + | }{ | |||
| 490 | + | {name: "info", sev: domain.NoticeInfo, want: 4 * time.Second}, | |||
| 491 | + | {name: "warn", sev: domain.NoticeWarn, want: 6 * time.Second}, | |||
| 492 | + | {name: "error", sev: domain.NoticeError, want: 8 * time.Second}, | |||
| 493 | + | } | |||
| 494 | + | for _, tc := range tests { | |||
| 495 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 496 | + | require.Equal(t, tc.want, noticeTTL(tc.sev)) | |||
| 497 | + | ||||
| 498 | + | clk := newNoticeClock() | |||
| 499 | + | d, sess, ac, _ := newNoticeFixture(t, clk) | |||
| 500 | + | d.notify(sess, tc.sev, domain.NoticeClipboard, "msg", nil) | |||
| 501 | + | awaitToastCount(t, ac, 1) | |||
| 502 | + | require.Equal(t, []time.Duration{tc.want}, clk.durations(), "the retained toast timer uses the severity TTL") | |||
| 503 | + | }) | |||
| 504 | + | } | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | func TestToastExpiresOnFakeClock(t *testing.T) { | |||
| 508 | + | clk := newNoticeClock() | |||
| 509 | + | d, sess, ac, sends := newNoticeFixture(t, clk) | |||
| 510 | + | ||||
| 511 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |||
| 512 | + | awaitToastCount(t, ac, 1) | |||
| 513 | + | drainFrames(sends) | |||
| 514 | + | ||||
| 515 | + | // Short of the 8s error TTL nothing expires. | |||
| 516 | + | clk.advance(7 * time.Second) | |||
| 517 | + | toasts, _ := visibleToasts(ac) | |||
| 518 | + | require.Len(t, toasts, 1, "toast must survive until its TTL elapses") | |||
| 519 | + | ||||
| 520 | + | clk.advance(2 * time.Second) | |||
| 521 | + | awaitToastCount(t, ac, 0) | |||
| 522 | + | _, overflow := visibleToasts(ac) | |||
| 523 | + | require.Zero(t, overflow, "overflow resets once no toast is visible") | |||
| 524 | + | ||||
| 525 | + | select { | |||
| 526 | + | case <-sends: | |||
| 527 | + | case <-time.After(2 * time.Second): | |||
| 528 | + | t.Fatal("expiry did not invalidate the render") | |||
| 529 | + | } | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | // TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a | |||
| 533 | + | // toast that outlived it. | |||
| 534 | + | func TestToastExpiryOnlyRemovesItsOwnEntry(t *testing.T) { | |||
| 535 | + | clk := newNoticeClock() | |||
| 536 | + | d, sess, ac, _ := newNoticeFixture(t, clk) | |||
| 537 | + | ||||
| 538 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil) // 4s TTL | |||
| 539 | + | awaitToastCount(t, ac, 1) | |||
| 540 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "boom", nil) // 8s TTL | |||
| 541 | + | awaitToastCount(t, ac, 2) | |||
| 542 | + | ||||
| 543 | + | clk.advance(5 * time.Second) | |||
| 544 | + | ||||
| 545 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 546 | + | require.Equal(t, domain.NoticePaneSpawn, toasts[0].Code, "only the expired info toast is removed") | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | // TestToastExpiryRemovesOnlyTheEntryItArmed pins the removal predicate: an | |||
| 550 | + | // expiry timer identifies its toast by entry sequence, not by code and scope. | |||
| 551 | + | // Coalescing re-arms the entry under a fresh sequence, so a timer that was | |||
| 552 | + | // stopped but had already slipped past its select cannot dismiss the refreshed | |||
| 553 | + | // toast that replaced the one it belonged to. | |||
| 554 | + | func TestToastExpiryRemovesOnlyTheEntryItArmed(t *testing.T) { | |||
| 555 | + | clk := newNoticeClock() | |||
| 556 | + | d, sess, ac, _ := newNoticeFixture(t, clk) | |||
| 557 | + | ||||
| 558 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil) | |||
| 559 | + | awaitToastCount(t, ac, 1) | |||
| 560 | + | ||||
| 561 | + | // Re-stamp the live entry's sequence, exactly as coalescing does, without | |||
| 562 | + | // disturbing the already-armed timer. Its code and scope are unchanged. | |||
| 563 | + | rt := ac.overlays | |||
| 564 | + | rt.noticeMu.Lock() | |||
| 565 | + | rt.noticeSeq++ | |||
| 566 | + | rt.noticeToasts[0].seq = rt.noticeSeq | |||
| 567 | + | rt.noticeMu.Unlock() | |||
| 568 | + | ||||
| 569 | + | clk.advance(4 * time.Second) | |||
| 570 | + | ||||
| 571 | + | require.Never(t, func() bool { | |||
| 572 | + | ns, _ := visibleToasts(ac) | |||
| 573 | + | return len(ns) == 0 | |||
| 574 | + | }, 200*time.Millisecond, 5*time.Millisecond, "expiry must remove only the entry it armed") | |||
| 575 | + | } | |||
M internal/usecase/daemon/overlay_runtime.go +10 -0
| @@ -48,6 +48,16 @@ type overlayRuntime struct { | |||
| 48 | 48 | copyPointer copyPointerState | |
| 49 | 49 | copyClick copyClickCandidate | |
| 50 | 50 | copyPointerEpoch uint64 | |
| 51 | + | ||
| 52 | + | // noticeMu is the innermost overlay lock: it guards the toast fields below | |
| 53 | + | // and nothing is ever locked, sent, or rendered while it is held. The only | |
| 54 | + | // permitted nesting is sendMu -> noticeMu (rendering reads the toasts). | |
| 55 | + | noticeMu sync.Mutex | |
| 56 | + | noticeToasts []noticeToast | |
| 57 | + | noticeOverflow int | |
| 58 | + | // noticeSeq numbers toast entries so an already-fired expiry timer cannot | |
| 59 | + | // dismiss the refreshed entry that replaced the one it belonged to. | |
| 60 | + | noticeSeq uint64 | |
| 51 | 61 | } | |
| 52 | 62 | ||
| 53 | 63 | type copyPointerState struct { | |
| @@ -48,6 +48,16 @@ type overlayRuntime struct { | |||||
| 48 | copyPointer copyPointerState | 48 | copyPointer copyPointerState | ||
| 49 | copyClick copyClickCandidate | 49 | copyClick copyClickCandidate | ||
| 50 | copyPointerEpoch uint64 | 50 | copyPointerEpoch uint64 | ||
| 51 | + | ||||
| 52 | + | // noticeMu is the innermost overlay lock: it guards the toast fields below | |||
| 53 | + | // and nothing is ever locked, sent, or rendered while it is held. The only | |||
| 54 | + | // permitted nesting is sendMu -> noticeMu (rendering reads the toasts). | |||
| 55 | + | noticeMu sync.Mutex | |||
| 56 | + | noticeToasts []noticeToast | |||
| 57 | + | noticeOverflow int | |||
| 58 | + | // noticeSeq numbers toast entries so an already-fired expiry timer cannot | |||
| 59 | + | // dismiss the refreshed entry that replaced the one it belonged to. | |||
| 60 | + | noticeSeq uint64 | |||
| 51 | } | 61 | } | ||
| 52 | 62 | ||||
| 53 | type copyPointerState struct { | 63 | type copyPointerState struct { | ||