feat(daemon): announce disabled persistence at attach
When the daemon can't open its session key-value store, persistence was silently disabled with only a log line. Queue a global WARN notice right after daemon construction so the first attaching client sees it, using Task 3's pending-global drain machinery.
- commit
- ac2dcc4551c89904b405e6c66b4c74f04db5a631
- author
- brice · · 2026-07-20 18:50 UTC
- parent
- 778ed2e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +37 -0
M internal/app/run.go +6 -0
| @@ -451,13 +451,19 @@ func runDaemon() (retErr error) { | |||
| 451 | 451 | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | |
| 452 | 452 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | |
| 453 | 453 | storePath := persist.StorePath(platform.StateDir()) | |
| 454 | + | var storeErr error | |
| 454 | 455 | if store, err := kv.Open(storePath); err != nil { | |
| 455 | 456 | log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err) | |
| 457 | + | storeErr = err | |
| 456 | 458 | } else { | |
| 457 | 459 | log.Info("session persistence enabled", "path", storePath) | |
| 458 | 460 | daemonOpts = append(daemonOpts, daemon.WithStore(store)) | |
| 459 | 461 | } | |
| 460 | 462 | d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...) | |
| 463 | + | if storeErr != nil { | |
| 464 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticePersistDisabled, | |
| 465 | + | "session persistence is disabled; sessions will not survive daemon restarts", storeErr) | |
| 466 | + | } | |
| 461 | 467 | watchCtx, stopWatch := context.WithCancel(ctx) | |
| 462 | 468 | defer stopWatch() | |
| 463 | 469 | go func() { | |
| @@ -451,13 +451,19 @@ func runDaemon() (retErr error) { | |||||
| 451 | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | 451 | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | ||
| 452 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | 452 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | ||
| 453 | storePath := persist.StorePath(platform.StateDir()) | 453 | storePath := persist.StorePath(platform.StateDir()) | ||
| 454 | + | var storeErr error | |||
| 454 | if store, err := kv.Open(storePath); err != nil { | 455 | if store, err := kv.Open(storePath); err != nil { | ||
| 455 | log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err) | 456 | log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err) | ||
| 457 | + | storeErr = err | |||
| 456 | } else { | 458 | } else { | ||
| 457 | log.Info("session persistence enabled", "path", storePath) | 459 | log.Info("session persistence enabled", "path", storePath) | ||
| 458 | daemonOpts = append(daemonOpts, daemon.WithStore(store)) | 460 | daemonOpts = append(daemonOpts, daemon.WithStore(store)) | ||
| 459 | } | 461 | } | ||
| 460 | d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...) | 462 | d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...) | ||
| 463 | + | if storeErr != nil { | |||
| 464 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticePersistDisabled, | |||
| 465 | + | "session persistence is disabled; sessions will not survive daemon restarts", storeErr) | |||
| 466 | + | } | |||
| 461 | watchCtx, stopWatch := context.WithCancel(ctx) | 467 | watchCtx, stopWatch := context.WithCancel(ctx) | ||
| 462 | defer stopWatch() | 468 | defer stopWatch() | ||
| 463 | go func() { | 469 | go func() { | ||
M internal/usecase/daemon/notify_test.go +31 -0
| @@ -443,6 +443,37 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |||
| 443 | 443 | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |
| 444 | 444 | } | |
| 445 | 445 | ||
| 446 | + | // TestNotifyGlobalPersistDisabledDrainsAtFirstAttach guards the store-open | |
| 447 | + | // failure contract (Task 13): app/run.go queues this exact code and message | |
| 448 | + | // via NotifyGlobal before any client has attached, and the first attach must | |
| 449 | + | // see it via firstPaint's pending-queue drain. | |
| 450 | + | func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) { | |
| 451 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 452 | + | sess.mu.Lock() | |
| 453 | + | sess.client = nil | |
| 454 | + | sess.mu.Unlock() | |
| 455 | + | ||
| 456 | + | cause := errors.New("open store.db: permission denied") | |
| 457 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticePersistDisabled, | |
| 458 | + | "session persistence is disabled; sessions will not survive daemon restarts", cause) | |
| 459 | + | ||
| 460 | + | require.Len(t, d.notices.history(), 1) | |
| 461 | + | toasts, _ := visibleToasts(ac) | |
| 462 | + | require.Empty(t, toasts, "no attached client means nothing to paint yet") | |
| 463 | + | ||
| 464 | + | // Re-attach and let firstPaint drain the pending queue. | |
| 465 | + | sess.mu.Lock() | |
| 466 | + | sess.client = ac | |
| 467 | + | sess.mu.Unlock() | |
| 468 | + | d.firstPaint(sess, ac, domain.Size{}) | |
| 469 | + | ||
| 470 | + | drained := awaitToastCount(t, ac, 1) | |
| 471 | + | require.Equal(t, domain.NoticePersistDisabled, drained[0].Code) | |
| 472 | + | require.Equal(t, domain.NoticeWarn, drained[0].Severity) | |
| 473 | + | require.Equal(t, "session persistence is disabled; sessions will not survive daemon restarts", drained[0].Message) | |
| 474 | + | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |
| 475 | + | } | |
| 476 | + | ||
| 446 | 477 | // levelCaptureHandler records the slog.Level of every record it's handed, so | |
| 447 | 478 | // tests can assert on log verbosity without parsing formatted output. | |
| 448 | 479 | type levelCaptureHandler struct { | |
| @@ -443,6 +443,37 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |||||
| 443 | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | 443 | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | ||
| 444 | } | 444 | } | ||
| 445 | 445 | ||||
| 446 | + | // TestNotifyGlobalPersistDisabledDrainsAtFirstAttach guards the store-open | |||
| 447 | + | // failure contract (Task 13): app/run.go queues this exact code and message | |||
| 448 | + | // via NotifyGlobal before any client has attached, and the first attach must | |||
| 449 | + | // see it via firstPaint's pending-queue drain. | |||
| 450 | + | func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) { | |||
| 451 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 452 | + | sess.mu.Lock() | |||
| 453 | + | sess.client = nil | |||
| 454 | + | sess.mu.Unlock() | |||
| 455 | + | ||||
| 456 | + | cause := errors.New("open store.db: permission denied") | |||
| 457 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticePersistDisabled, | |||
| 458 | + | "session persistence is disabled; sessions will not survive daemon restarts", cause) | |||
| 459 | + | ||||
| 460 | + | require.Len(t, d.notices.history(), 1) | |||
| 461 | + | toasts, _ := visibleToasts(ac) | |||
| 462 | + | require.Empty(t, toasts, "no attached client means nothing to paint yet") | |||
| 463 | + | ||||
| 464 | + | // Re-attach and let firstPaint drain the pending queue. | |||
| 465 | + | sess.mu.Lock() | |||
| 466 | + | sess.client = ac | |||
| 467 | + | sess.mu.Unlock() | |||
| 468 | + | d.firstPaint(sess, ac, domain.Size{}) | |||
| 469 | + | ||||
| 470 | + | drained := awaitToastCount(t, ac, 1) | |||
| 471 | + | require.Equal(t, domain.NoticePersistDisabled, drained[0].Code) | |||
| 472 | + | require.Equal(t, domain.NoticeWarn, drained[0].Severity) | |||
| 473 | + | require.Equal(t, "session persistence is disabled; sessions will not survive daemon restarts", drained[0].Message) | |||
| 474 | + | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |||
| 475 | + | } | |||
| 476 | + | ||||
| 446 | // levelCaptureHandler records the slog.Level of every record it's handed, so | 477 | // levelCaptureHandler records the slog.Level of every record it's handed, so | ||
| 447 | // tests can assert on log verbosity without parsing formatted output. | 478 | // tests can assert on log verbosity without parsing formatted output. | ||
| 448 | type levelCaptureHandler struct { | 479 | type levelCaptureHandler struct { | ||