Skip to content

fix: state consistency across daemon, playlists, and wal-qt backend - #239

Merged
0bCdian merged 42 commits into
mainfrom
fix/state-consistency
Jul 26, 2026
Merged

fix: state consistency across daemon, playlists, and wal-qt backend#239
0bCdian merged 42 commits into
mainfrom
fix/state-consistency

Conversation

@0bCdian

@0bCdian 0bCdian commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

A batch of state-consistency fixes accumulated across the daemon, playlist manager, and wal-qt backend integration, plus follow-on cleanup that fell out of them:

  • Playlist manager: fixes for missed-event recovery on restore, scheduler goroutine leaks on resume, restored-paused schedulers starting already paused, atomic/deduped monitor-state upsert on restore, wall-clock comparison for missed-event deadlines, publishing the real slot start time so the playlist progress bar renders correctly, and moving playlist sync dedupe state into the store.
  • Backend / wal-qt: serialized backend switching with the activated backend returned from the call, Active() returns nil instead of panicking when no backend is active, guarding web config push against a nil active backend, ordering Restore before playlist restore, treating wal-qt load as successful only when targets confirm, actually transmitting allow_network_wallpapers to wal-qt, and no longer retaining raw *viper.Viper across the six wallpaper backends.
  • Config: guard the viper file watcher against concurrent access; release the watcher goroutine on Close.
  • Electron/UI: resync daemon state on system resume; refetch daemon state when the SSE stream reconnects.
  • API contract / docs: daemon/docs/openapi.yaml is now the single source of truth for the HTTP API — removed swagger.yaml, the ci:openapi script, //go:generate swag init, and the now-orphaned // @Summary/@Tags/@Param/... swag annotation comments across all daemon/internal/handler/** files (doc-comment prose, e.g. on WallpaperHandler.GetCurrent, was preserved). Added internal/apispec tests that verify openapi.yaml schemas against the corresponding Go structs in both directions, and fixed drift the new test caught (stale BackendCapabilities fields, missing active on BackendInfo, missing error_code/meta on APIError, HealthzResponse pointed at the wrong schema).
  • Dead code / cleanup: removed verified-unreachable code in the daemon and wal-qt paths, replaced ad hoc map[string]any HTTP responses with typed structs, trimmed comments that only restated the code.

Test plan

  • go build ./... (daemon)
  • go vet ./... (daemon)
  • gofmt -l ./internal/ ./cmd/ clean
  • go test -race ./... -count=1 (daemon) — all packages pass
  • pnpm run ci:check (repo-wide) — not run in this session; electron/daemonForward.ts has an uncommitted maintainer edit outside this branch's scope that fails format:check, so individual daemon-side commands were used instead
  • Manual verification of the wal-qt daemon contract changes (flagged as still needing manual check)

0bCdian added 30 commits July 25, 2026 12:36
time_of_day and day_of_week set nextChange inside their goroutine, so
startPlaylist's synchronous read stored next_change_at=nil. That hid the
UI progress bar and made missedEventChecker short-circuit forever.
Go timers use CLOCK_MONOTONIC, which does not advance across suspend, so
timer playlists stalled after resume with a wall-clock NextChangeAt in
the past and no watchdog to notice.
Set was a delete-then-insert with no unique index, so concurrent writers
could leave two rows per monitor and a crash between the two statements
lost the row entirely. BuildSnapshot now keeps the newest row per monitor.
Resume() spawned a second loop while the original stayed parked on a
stopped timer, and the parked goroutine read s.timer without the mutex.
Both schedulers now own one goroutine for life and park on resumeCh.
startPlaylist called Pause() after Start(), which raced the day_of_week
immediate fire and applied a wallpaper for a paused playlist — colliding
with the concurrent wallpaper.Restore on the fromPersisted path.
The daemon bus drops events for slow subscribers with no replay and the
client backs off up to 60s, so everything published during a suspend was
lost and the stores kept stale values indefinitely.

Extracted the active-playlist refetch logic that already existed as a
local closure inside useSetLastActivePlaylist into an exported
refreshActivePlaylist(), now shared by both that hook's daemon-event
listeners and the new useResyncOnReconnect hook.
Nothing in the stack knew a suspend had happened; the daemon caches its
monitor list for the process lifetime and only refreshes on GET /monitors.
IPCManager.notifySystemResumed() broadcasts go-daemon-event-system_resumed,
which powerMonitor's "resume" listener in main.ts triggers; the renderer
now treats system_resumed exactly like sse_reconnected.

preload.ts's `on()` forwards go-daemon-event-* channels generically, keyed
off the EventType union in electron/daemon-go-types.ts — added
"system_resumed" there so the channel isn't rejected at the type level.
…ckend

Shutdown -> SetActive -> Initialize was unguarded, so concurrent auto-mode
ticks and manual sets could interleave and apply to a backend that had
already been shut down while the DB recorded success.
The daemon supports a degraded no-backend start, so GET /wallpaper/current
and the incompatible_backend error path on /wallpaper/set and
/wallpaper/random turned a supported state into a panic and an opaque 500.

Also switch SwitchActiveBackend's post-SetActive backend lookup back to
Active() (from Get()) so it keeps working against MockRegistry fakes that
only implement ActiveFn — Active() is guaranteed non-nil immediately after
a successful SetActive under switchMu, and now that Active() returns nil
instead of panicking the same nil-check covers both call sites.
Restore bypassed the per-backend apply gate, so a config PATCH concurrent
with a playlist tick could reach the compositor out of order. Startup also
raced Restore against RestorePersistedRuns, which assumes Restore already ran.
Task 7 made Registry.Active() return nil in the supported no-backend
degraded mode, which turned these unguarded derefs into a panic on the
web wallpaper config PATCH path.

Also extracts missedEventTargetIndex so the suspend-recovery slide
selection is covered behaviourally, not just by a wiring predicate.
wal-qt now reports per-target outcomes, so stop recording a wallpaper as
applied on the strength of an ack that was sent before anything loaded.
Go carries out t.After(u) using the monotonic clock alone when both values
hold a monotonic reading, and CLOCK_MONOTONIC does not advance while the
machine is suspended. missedEventChecker compared two such values, so the
watchdog whose entire purpose is catching a transition missed across a
suspend was structurally blind to it: after an 8h sleep the check answered
'not due' until the original interval elapsed in awake time.

Schedulers now publish wall-only deadlines (observable: no m=+ suffix) and
the due-check strips monotonic readings defensively.
ViperManager used viper's built-in WatchConfig/OnConfigChange, whose
watcher goroutine calls v.ReadInConfig() directly on the shared
*viper.Viper, completely unguarded by ViperManager's mutex. Every
config-writing method (mergeAndSet, persistKeyReplace,
ResetToFactoryDefaults) rewrites the exact file being watched, so in
production a config write's own fsnotify event races with concurrent
reads/writes going through m.v — reproducible under `go test -race`
and reachable via real HTTP config-write paths, not a test artifact.

Replace viper's internal watcher with our own fsnotify loop
(mirroring its directory-watch/symlink-swap algorithm) that takes
m.mu before reloading m.v and releases it before invoking
notifyCallbacks, so the watcher goroutine can never touch m.v
concurrently with any other ViperManager method, and can't deadlock
since it never fires from inside an already-locked call.

Add a regression test driving concurrent SetBackendConfig/
UpdateConfig/GetConfig/GetSection, which fails under -race before
this fix and passes after.
…ry code

useSetLastActivePlaylist's lastSyncedPlaylistId was hoisted to module scope
earlier today so refreshActivePlaylist (called from useResyncOnReconnect)
could share it. Module-scoped mutable state only works because the hook
happens to mount once app-wide, and it never resets between test cases.
Move it into useActivePlaylistStore alongside the state it's already
paired with; clear() now resets both together, preserving the original
dedupe semantics exactly.

Also remove isRetryableControlStatusErr and isRetryableUnixSocketDial from
daemon/internal/backend/walqt/errors.go: both were left dead by the
getStatusWithRetry removal in f1fe652 (per grep and `go run
golang.org/x/tools/cmd/deadcode`), referenced only by their own now-deleted
tests.
…rrectly

The renderer previously inferred a slot's start time from when it first
observed next_change_at, which is wrong whenever the UI connects mid-slot
(GUI restart, daemon-then-renderer startup lag) or the deadline has already
passed. The daemon now stamps SlotStartedAt (wall-clock only, alongside
NextChangeAt) at every point it schedules a deadline, and the UI uses it to
compute the real elapsed/total window, falling back to first-seen timing
when talking to an older daemon. Overdue slots now render 100%/0 remaining
instead of a dead "—" bar.
Remove the shadowtest package (self-testing only, no external callers),
unused MockDB/MockMonitorProvider/MockScheduler test doubles, and
unreferenced helpers ValidateAutoPriorities/categoryToMediaType,
ContentToMediaType, and Controller.GetConfig. All confirmed dead via
grep and go deadcode; unreleased/rewrite-friendly codebase per repo
conventions.
The npm package "ci" has 0 imports anywhere in src/electron/shared/
globals/e2e and was misplaced under dependencies instead of
devDependencies. Ran pnpm install to update the lockfile.
…ape hatch

ViperManager's fsnotify watcher goroutine (introduced in 1e58de0) was never
stopped, leaking one inotify instance per ViperManager for the life of the
process. Add an idempotent Close() that stops the goroutine and releases the
watcher, and call it from daemon shutdown via an io.Closer type-assertion
(ConfigManager stays untouched so existing fakes/mocks keep compiling).

Also audited every Viper() caller: two (image.Processor's stored-but-unused
configViper field, walqt's monitor provider) retain the raw *viper.Viper
beyond startup and read it later, unguarded by ViperManager's mutex — a real
latent race with the watcher goroutine, not just ordering luck. Document the
constraint precisely on Viper() rather than deleting it, since those callers
genuinely need a long-lived instance and live outside this package's scope.
The spec declared BackendCapabilities.media_types, but backend.Capabilities
has always serialized as content_kinds (electron/daemon-go-types.ts and the
test mock already agreed on that) — the spec was the thing out of sync.
ci:types only diffs the generated TS against the spec, never the spec
against the Go struct, so this could drift forever without failing CI.
Add a Go test that reflects over backend.Capabilities' json tags and checks
each appears in the BackendCapabilities schema, closing that hole.
cfg.AllowNetworkWallpapers was parsed, defaulted, and exposed in the UI, but
controlClient.setAllowNetworkWallpapers was never called from anywhere, so
the setting had no effect (wal-qt always ran with its own fail-closed
default). Push it from Apply, gated on the load being a web wallpaper, right
before /wallpaper/load — this covers daemon startup with an already-running
wal-qt (Apply runs as part of restore) and a runtime PATCH to the backend
config (UpdateBackendConfig re-applies the active snapshot), without adding
a round-trip to every image/video Apply.
setImagePresentation, the topology-matching helpers (TopologyMonitorMatch,
TopologyMonitorContainingCenter, TopologyMonitorMatchByPosition,
ResolveParallaxMonitor, approxEqTopology), and the NewWalQtWithHTTPClient
test helper had no callers anywhere in the daemon. Removes their tests too
(mapping_test.go existed solely to exercise the topology helpers).
…backends

All six backends (awww, feh, hyprpaper, mpvpaper, swaybg, wal-qt) and
wal-qt's monitor provider captured the *viper.Viper handed to
RegisterDefaults and read it later at runtime — every Apply, every
GET /monitors — unsynchronized. ViperManager's config-file watcher
goroutine reloads that same instance (ReadInConfig, a map write) under
its own mutex, which those retained-pointer reads never took: a proven,
reachable data race on the daemon's hottest path (confirmed via
-race, 206 warnings from the reproduction in this commit's regression
test run against the old code).

Add backend.ConfigReader, a 6-method interface covering exactly what the
backends call (GetString/GetInt/GetFloat64/GetBool/GetStringSlice/IsSet),
and backend.ConfigReaderReceiver, an optional interface backends
implement via SetConfigReader. *config.ViperManager implements
ConfigReader with its own RWMutex-guarded methods. main.go now calls
SetConfigReader(cfg) right after RegisterDefaults for every backend, and
passes cfg (not the raw viper) into walqt.NewMonitorProvider. Backend
struct fields change type from *viper.Viper to backend.ConfigReader;
every internal read call site (w.v.GetString(...) etc) is untouched.

Also drop image.Processor's configViper field/param: grep confirmed it
was never read, so it was dead retained state of the same shape.

internal/backend/walqt/config_race_test.go is the permanent -race guard,
covering both WalQt.loadConfigFromViper and the monitor provider's
controlConfig against a real ViperManager under concurrent
SetActiveBackendType calls (which exercise the same reload path as the
file watcher).
daemon/docs/swagger.yaml was Swagger 2.0 generated by swag from
@router annotations, last regenerated May 22, and fed nothing
downstream (swag isn't even installed, so ci:openapi couldn't run).
daemon/docs/openapi.yaml is OpenAPI 3.0.3, hand-maintained, and feeds
generate:types -> electron/daemon-go-types.generated.ts. Consolidating
to a single spec per maintainer decision.

Per-handler swag annotation comments (@summary, @router, etc.) are
left in place; their fate is a separate decision owned by other
in-flight work.
ci:check silently skipped the spec-drift check (ci:types) and the
race detector (test:daemon:race), so both existed only as one-off
manual commands that rot the moment someone forgets to run them.

test:daemon:race replaces test:daemon:unit rather than running
alongside it: verified both scripts execute the exact same set of
610 tests (diff of `go test -v` output is empty), so running unit
mode and then race mode back to back would just repeat identical
work for no extra coverage. Race mode is strictly stronger (same
tests plus data-race detection) at a modest cost (~7.5s vs ~2.5s
locally), so it's a straight upgrade in the same slot.

ci:types is placed right after build:daemon, since build:daemon
already regenerates the types as a side effect of make daemon; the
extra step is just the git diff check to catch an uncommitted/stale
generated file.
Committed unformatted earlier today; now blocks ci:check since format:check
became part of the ratchet.
…yped structs

Gives all 25 remaining untyped daemon handler responses a named Go struct
(one per file, matching the existing WallpaperCurrentResponse convention),
so a follow-up task can assert Go response shapes against openapi.yaml.
Wire format is unchanged; added marshal-comparison tests for endpoints with
no existing response-shape test coverage.
swagger.yaml and ci:openapi were removed earlier; the //go:generate swag
directive, its package-level @title/@version/@basepath annotations, and the
generate:openapi script that triggered it were the last remnants. swag was
never installed, so none of it could run.

package.json key order is oxfmt's canonical output — format:check is part of
ci:check now, and the file did not conform.
Viper() carried 23 lines of prose for a one-line getter, and it had already
gone stale: it warned that walqt's monitor provider retains a raw *viper.Viper,
which stopped being true when ConfigReader landed.

Kept only what the code cannot state itself — chiefly that Round(0) is load
bearing, since Go compares monotonic times without the wall clock.
0bCdian added 12 commits July 25, 2026 18:14
Drops an IDAllocator usage example that duplicated a real call site ten lines
away, an SSE frame-format block visible in the writer, and an xrandr sample
already shown next to each parser.

Kept the wl_output limitations and the source-target contract: neither is
derivable from the code, and both are silent-failure traps.
…rections

Generalises the walqt-only BackendCapabilities check into one test package
(internal/apispec) covering every schema in openapi.yaml that has a
corresponding Go type. For each mapped schema, asserts json tags on the Go
struct and spec properties are the same set:

- Go -> spec: every json tag must appear in the schema (catches an
  undocumented/renamed Go field, e.g. the historical media_types vs
  content_kinds drift).
- spec -> Go: every schema property must exist on the Go struct (catches a
  spec property documenting behaviour Go never implemented).

Replaces the single-purpose capabilities_spec_test.go in
internal/backend/walqt, which this supersedes.
swag was never installed and swagger.yaml/ci:openapi/go:generate swag init
were already deleted; daemon/docs/openapi.yaml is now the sole API spec
source of truth. The // @Summary/@Tags/@Param/@Success/@Failure/@router
annotation blocks generated nothing and could silently drift from it, so
strip them, keeping the plain doc-comment lines (including the tie-break
prose on WallpaperHandler.GetCurrent).
… test

BackendCapabilities documented transitions/per_monitor/daemon_process, none
of which exist on backend.Capabilities (dead fields, likely stale since
before the struct was last trimmed) — removed. BackendInfo was missing the
`active` field backend.BackendInfo actually serializes. APIError was missing
error_code and meta, both populated by httpjson.WriteStructuredError.

Also replaces GenericJSON/inline-object placeholders with dedicated schemas
for the 25 handler responses that gained named Go structs in 41ba414, plus
the pre-existing WallpaperCurrentResponse, and adds the previously
undocumented GET /capabilities path. HealthzResponse in particular was
pointed at StatusOk, which only declares `status` — Go actually also sends
monitor_stack_version and monitor_provider_order.

Regenerated electron/daemon-go-types.generated.ts to match.
…its interval

The test assigned ts.interval while runLoop was already parked on a timer
built from the old 3600s value, so a tick only arrived if the reconcile's
own channel sync happened to land after the override — 5 failures in 12
runs. AfterManualNavigation blocks until the loop restarts its wait.

Matters more now that test:daemon:race gates ci:check.
# Conflicts:
#	daemon/internal/backend/walqt/walqt.go
#	daemon/internal/wallpaper/restore_test.go
Watch logind's PrepareForSleep signal and, on resume, force a monitor re-detect and re-run Restore() so a monitor dropped across a suspend is not left wallpaper-less. Best-effort: no logind/system bus logs a warning and runs on.
@0bCdian
0bCdian merged commit fd5819b into main Jul 26, 2026
2 checks passed
@0bCdian
0bCdian deleted the fix/state-consistency branch July 26, 2026 09:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant