1package daemon
2
3import (
4 "context"
5 "errors"
6 "log/slog"
7 "strings"
8 "sync"
9 "time"
10
11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"
13)
14
15const (
16 noticeHistoryCap = 200
17 noticePendingCap = 32
18 // maxVisibleToasts bounds the stack drawn over a client's screen; anything
19 // trimmed is counted in noticeOverflow and stays reachable in history.
20 maxVisibleToasts = 3
21)
22
23// errNoNeighbor reports a directional focus move with nothing to move to. It is
24// an ordinary outcome of navigation, never a user-facing failure.
25var errNoNeighbor = errors.New("no pane in that direction")
26
27// noticeCenter owns the daemon-wide notification history and the queue of
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.
30type noticeCenter struct {
31 mu sync.Mutex
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 routingMu sync.Mutex
36 ring []domain.Notification
37 pending []domain.Notification
38 // beforeQueueGlobal is a deterministic test seam after routing has observed
39 // no attachments and before it queues the notice.
40 beforeQueueGlobal func()
41 // beforeGlobalDelivery is a deterministic test seam after targets have been
42 // selected and before their toast stacks are touched.
43 beforeGlobalDelivery func()
44 // beforeSessionDelivery is a deterministic test seam after a session client
45 // has been selected and before its toast stack is touched.
46 beforeSessionDelivery func()
47 // beforeClientNoticeMutation is a deterministic test seam after a client
48 // notice's ownership validation and before its toast state is changed.
49 beforeClientNoticeMutation func()
50}
51
52func newNoticeCenter() *noticeCenter { return ¬iceCenter{} }
53
54func (nc *noticeCenter) record(n domain.Notification) domain.Notification {
55 if n.Count == 0 {
56 n.Count = 1
57 }
58 nc.mu.Lock()
59 defer nc.mu.Unlock()
60 nc.ring = append(nc.ring, n)
61 if len(nc.ring) > noticeHistoryCap {
62 nc.ring = nc.ring[len(nc.ring)-noticeHistoryCap:]
63 }
64 return n
65}
66
67// recordSnapshotFailure coalesces a repeated stable snapshot failure in the
68// history. Details contains the signature rather than raw error text, so this
69// comparison cannot be affected by volatile paths or snapshot content.
70func (nc *noticeCenter) recordSnapshotFailure(n domain.Notification) (domain.Notification, bool) {
71 if n.Count == 0 {
72 n.Count = 1
73 }
74 nc.mu.Lock()
75 defer nc.mu.Unlock()
76 for i := len(nc.ring) - 1; i >= 0; i-- {
77 previous := &nc.ring[i]
78 if previous.Code != domain.NoticeSnapshotWrite || previous.SessionID != n.SessionID || previous.Details != n.Details {
79 continue
80 }
81 previous.Count += n.Count
82 previous.Time = n.Time
83 return *previous, true
84 }
85 nc.ring = append(nc.ring, n)
86 if len(nc.ring) > noticeHistoryCap {
87 nc.ring = nc.ring[len(nc.ring)-noticeHistoryCap:]
88 }
89 return n, false
90}
91
92func (nc *noticeCenter) history() []domain.Notification {
93 nc.mu.Lock()
94 defer nc.mu.Unlock()
95 out := make([]domain.Notification, len(nc.ring))
96 for i, n := range nc.ring {
97 out[len(nc.ring)-1-i] = n
98 }
99 return out
100}
101
102func (nc *noticeCenter) latest() (domain.Notification, bool) {
103 nc.mu.Lock()
104 defer nc.mu.Unlock()
105 if len(nc.ring) == 0 {
106 return domain.Notification{}, false
107 }
108 return nc.ring[len(nc.ring)-1], true
109}
110
111func (nc *noticeCenter) queueGlobal(n domain.Notification) {
112 if n.Count == 0 {
113 n.Count = 1
114 }
115 nc.mu.Lock()
116 defer nc.mu.Unlock()
117 for i := range nc.pending {
118 if sameToastNotice(nc.pending[i], n) {
119 nc.pending[i].Count += n.Count
120 nc.pending[i].Time = n.Time
121 return
122 }
123 }
124 if len(nc.pending) >= noticePendingCap {
125 return // history already has it via record(); toast is dropped
126 }
127 nc.pending = append(nc.pending, n)
128}
129
130func (nc *noticeCenter) drainPending() []domain.Notification {
131 nc.mu.Lock()
132 defer nc.mu.Unlock()
133 out := nc.pending
134 nc.pending = nil
135 return out
136}
137
138// noticeToast is one entry in a client's visible toast stack. seq identifies the
139// entry across coalescing so a stale expiry timer removes only its own toast.
140type noticeToast struct {
141 n domain.Notification
142 seq uint64
143 timer pendingByteTimer
144}
145
146// noticeTTL is how long a toast of this severity stays visible.
147func noticeTTL(sev domain.NoticeSeverity) time.Duration {
148 switch sev {
149 case domain.NoticeInfo:
150 return 4 * time.Second
151 case domain.NoticeWarn:
152 return 6 * time.Second
153 default:
154 return 8 * time.Second
155 }
156}
157
158// slogLevelFor maps a notice severity to the slog level it should log at, so
159// routine info notices (e.g. a successful clipboard copy) don't drown out
160// real warnings at the default VEV_LOG=info verbosity.
161func slogLevelFor(sev domain.NoticeSeverity) slog.Level {
162 switch sev {
163 case domain.NoticeInfo:
164 return slog.LevelInfo
165 case domain.NoticeWarn:
166 return slog.LevelWarn
167 default:
168 return slog.LevelError
169 }
170}
171
172// noticeDetails renders the full Unwrap chain for the yank/history views. The
173// toast itself never shows this — only the UserError's own message.
174func noticeDetails(err error) string {
175 if err == nil {
176 return ""
177 }
178 var parts []string
179 for e := err; e != nil; e = errors.Unwrap(e) {
180 parts = append(parts, e.Error())
181 }
182 return strings.Join(parts, " ← ")
183}
184
185// benignNoticeError reports errors that are expected control flow rather than
186// something the user needs to be told about.
187func benignNoticeError(err error) bool {
188 return errors.Is(err, context.Canceled) ||
189 errors.Is(err, errNoNeighbor) ||
190 errors.Is(err, ports.ErrNoClipboardImage)
191}
192
193// persistShutdownSnapshotFailure records a terminal snapshot failure that
194// cannot be toasted because daemon shutdown has already begun. It belongs to
195// notification ownership so session teardown only decides whether shutdown
196// makes the failure non-retryable.
197func (d *Daemon) persistShutdownSnapshotFailure(name string, cause error) {
198 if d.noticeStore == nil {
199 return
200 }
201 d.shutdownNoticeMu.Lock()
202 if d.shutdownNoticedSessions == nil {
203 d.shutdownNoticedSessions = make(map[string]struct{})
204 }
205 if _, exists := d.shutdownNoticedSessions[name]; exists {
206 d.shutdownNoticeMu.Unlock()
207 return
208 }
209 d.shutdownNoticedSessions[name] = struct{}{}
210 d.shutdownNoticeMu.Unlock()
211 if err := d.noticeStore.Append(domain.Notification{
212 Code: domain.NoticeSnapshotWrite,
213 Severity: domain.NoticeError,
214 Message: "session " + name + " shut down without saving terminal state",
215 Details: noticeDetails(cause),
216 Time: d.clock.Now(),
217 }); err != nil {
218 d.shutdownNoticeMu.Lock()
219 delete(d.shutdownNoticedSessions, name)
220 d.shutdownNoticeMu.Unlock()
221 d.log.Warn("persisting shutdown notice failed", "err", err, "session", name, "action", "final-checkpoint-timeout")
222 }
223}
224
225// reportError turns any error into a user-facing notice. Unclassified errors
226// become NoticeInternal: an error reaching here is never silently dropped
227// unless benignNoticeError says it is routine.
228func (d *Daemon) reportError(sess *session, err error) {
229 if err == nil || benignNoticeError(err) {
230 return
231 }
232 if ue, ok := errors.AsType[*domain.UserError](err); ok {
233 d.notify(sess, ue.Severity, ue.Code, ue.Msg, ue.Err)
234 return
235 }
236 d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err)
237}
238
239func (d *Daemon) reportAttachmentError(entry *session, err error) {
240 d.reportError(entry, err)
241}
242
243// notify records a notice and routes it to whoever can see it. A nil sess means
244// daemon-global: every attached client gets it, and if none is attached the
245// notice waits in the pending queue for the next attach. A detached session's
246// notice waits for that session's next attach.
247//
248// Locking: sess.mu and d.mu are only held to snapshot; showToast is always
249// called with no daemon, session, or pane lock held. Routing holds the notice
250// center's routingMu across selection and publication, which serializes it with
251// attachment detach and pending-queue drains. The order is routingMu -> sess.mu.
252func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
253 n := domain.Notification{
254 Code: code,
255 Severity: sev,
256 Message: msg,
257 Details: noticeDetails(cause),
258 Time: d.clock.Now(),
259 }
260 if sess != nil {
261 n.SessionID = sess.id
262 }
263 n = d.notices.record(n)
264 d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
265
266 if sess != nil {
267 d.notices.routingMu.Lock()
268 sess.mu.Lock()
269 attachments := sess.snapshotAttachmentsLocked()
270 sess.mu.Unlock()
271 if len(attachments) == 0 {
272 d.notices.queueGlobal(n)
273 d.notices.routingMu.Unlock()
274 return
275 }
276 if d.notices.beforeSessionDelivery != nil {
277 d.notices.beforeSessionDelivery()
278 }
279 published := make([]*attachedClient, 0, len(attachments))
280 for _, ac := range attachments {
281 if d.publishToast(ac, n) {
282 published = append(published, ac)
283 }
284 }
285 d.notices.routingMu.Unlock()
286 for _, ac := range published {
287 d.repaintForNotice(ac)
288 }
289 return
290 }
291
292 d.deliverGlobal(n)
293}
294
295// deliverGlobal atomically selects all current attachments and either publishes
296// to all of them or queues the notice. routingMu is held through toast-state
297// publication, but never through repaint: detach cannot turn a selected target
298// stale before publication, while rendering remains free to re-enter routing.
299func (d *Daemon) deliverGlobal(n domain.Notification) {
300 d.mu.Lock()
301 d.notices.routingMu.Lock()
302 sessions := make([]*session, 0, len(d.sessions))
303 for _, entry := range d.sessions {
304 sessions = append(sessions, entry)
305 }
306 d.mu.Unlock()
307
308 if d.notices.beforeGlobalDelivery != nil {
309 d.notices.beforeGlobalDelivery()
310 }
311 targets := make([]*attachedClient, 0, len(sessions))
312 for _, s := range sessions {
313 if s == nil {
314 continue
315 }
316 for _, ac := range s.snapshotAttachments() {
317 if !d.publishToast(ac, n) {
318 continue
319 }
320 targets = append(targets, ac)
321 }
322 }
323 if len(targets) == 0 {
324 if d.notices.beforeQueueGlobal != nil {
325 d.notices.beforeQueueGlobal()
326 }
327 d.notices.queueGlobal(n)
328 }
329 d.notices.routingMu.Unlock()
330
331 for _, ac := range targets {
332 d.repaintForNotice(ac)
333 }
334}
335
336// drainPendingForFirstPaint publishes pending globals and notices scoped to
337// sess only while ac remains the session's current attachment. It shares
338// routingMu with notice routing, so an unattached route cannot queue after this
339// drain has already happened.
340func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
341 d.notices.routingMu.Lock()
342
343 sess.mu.Lock()
344 current := attachmentRegisteredLocked(sess, ac)
345 sess.mu.Unlock()
346 if !current {
347 d.notices.routingMu.Unlock()
348 return
349 }
350 published := false
351 var keep []domain.Notification
352 for _, n := range d.notices.drainPending() {
353 if n.SessionID == "" || n.SessionID == sess.id {
354 published = d.publishToast(ac, n) || published
355 continue
356 }
357 keep = append(keep, n)
358 }
359 for _, n := range keep {
360 d.notices.queueGlobal(n)
361 }
362 d.notices.routingMu.Unlock()
363 if published {
364 d.repaintForNotice(ac)
365 }
366}
367
368// NotifyGlobal raises a daemon-wide notice from outside the daemon package.
369func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
370 d.notify(nil, sev, code, msg, cause)
371}
372
373// publishToast durably mutates one client's toast state without repainting.
374// Routing paths call it while routingMu excludes detach, then release routingMu
375// before rendering so a render failure can safely route another notice.
376func (d *Daemon) publishToast(ac *attachedClient, n domain.Notification) bool {
377 if ac == nil {
378 return false
379 }
380 ac.initOverlays()
381 rt := ac.overlays
382
383 rt.noticeMu.Lock()
384 defer rt.noticeMu.Unlock()
385 if i := rt.indexOfMatchingToastLocked(n); i >= 0 {
386 rt.noticeToasts[i].n.Count += n.Count
387 rt.noticeToasts[i].n.Time = n.Time
388 rt.noticeToasts[i].timer.stop()
389 d.retainToastTimerLocked(ac, &rt.noticeToasts[i])
390 return true
391 }
392
393 toast := noticeToast{n: n}
394 rt.noticeToasts = append([]noticeToast{toast}, rt.noticeToasts...)
395 if len(rt.noticeToasts) > maxVisibleToasts {
396 for i := maxVisibleToasts; i < len(rt.noticeToasts); i++ {
397 rt.noticeToasts[i].timer.stop()
398 rt.noticeOverflow++
399 }
400 rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts]
401 }
402 d.retainToastTimerLocked(ac, &rt.noticeToasts[0])
403 return true
404}
405
406func sameToastNotice(a, b domain.Notification) bool {
407 if a.Code != b.Code || a.SessionID != b.SessionID {
408 return false
409 }
410 // NoticeUser is intentionally generic: its message and selected severity
411 // are the notice identity. Typed daemon notices retain their established
412 // code-and-scope coalescing behavior.
413 if a.Code != domain.NoticeUser {
414 return true
415 }
416 return a.Severity == b.Severity &&
417 a.Message == b.Message &&
418 a.Details == b.Details
419}
420
421// indexOfMatchingToastLocked finds a visible toast with the code-specific
422// coalescing identity. Count and time are occurrence metadata, not identity.
423// Callers must hold noticeMu.
424func (rt *overlayRuntime) indexOfMatchingToastLocked(n domain.Notification) int {
425 for i := range rt.noticeToasts {
426 if sameToastNotice(rt.noticeToasts[i].n, n) {
427 return i
428 }
429 }
430 return -1
431}
432
433// indexOfToastLocked finds a visible toast with the same code and scope.
434// Callers must hold noticeMu.
435func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain.SessionID) int {
436 for i := range rt.noticeToasts {
437 if rt.noticeToasts[i].n.Code == code && rt.noticeToasts[i].n.SessionID == sid {
438 return i
439 }
440 }
441 return -1
442}
443
444// dismissToast removes only the specified visible toast. In particular, a
445// link-connected event must not clear clipboard or other notice state.
446func (d *Daemon) dismissToastWithoutRepaint(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) bool {
447 if ac == nil || ac.overlays == nil {
448 return false
449 }
450 rt := ac.overlays
451 rt.noticeMu.Lock()
452 defer rt.noticeMu.Unlock()
453 i := rt.indexOfToastLocked(code, sid)
454 if i < 0 {
455 return false
456 }
457 rt.noticeToasts[i].timer.stop()
458 rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...)
459 if len(rt.noticeToasts) == 0 {
460 rt.noticeOverflow = 0
461 }
462 return true
463}
464
465// retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the
466// timer goroutine re-acquires it and releases it before repainting, so the
467// callback never runs with noticeMu held.
468func (d *Daemon) retainToastTimerLocked(ac *attachedClient, t *noticeToast) {
469 rt := ac.overlays
470 rt.noticeSeq++
471 t.seq = rt.noticeSeq
472 seq := t.seq
473 t.timer.retain(d.clock, noticeTTL(t.n.Severity), func(ports.Timer) {
474 rt.noticeMu.Lock()
475 kept := rt.noticeToasts[:0]
476 for _, tt := range rt.noticeToasts {
477 if tt.seq == seq {
478 continue
479 }
480 kept = append(kept, tt)
481 }
482 rt.noticeToasts = kept
483 if len(rt.noticeToasts) == 0 {
484 rt.noticeOverflow = 0
485 }
486 rt.noticeMu.Unlock()
487 d.repaintForNotice(ac)
488 })
489}
490
491// repaintForNotice asks for an urgent redraw. It must be called with noticeMu
492// released: invalidateRender can paint inline, which takes sendMu.
493func (d *Daemon) repaintForNotice(ac *attachedClient) {
494 sess := ac.currentSession()
495 if sess == nil {
496 // Mid-handoff or torn down: whoever attaches next repaints anyway, and
497 // invalidateRender cannot paint without a session.
498 return
499 }
500 // Toast footprints carry their own old/new damage, so a normal notice
501 // transition can remain incremental. First paint and transport recovery
502 // still provide their independent reset invariants.
503 d.invalidateRender(sess, ac, false, "notify.go")
504}