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