Skip to content

Event-driven state updates: replace polling with daemon-pushed deltas #29

@jmylchreest

Description

@jmylchreest

Context

keylightd-tray (and any other client) currently polls the daemon for state via the existing CRUD API (GetLights, GetGroups, etc.) at a fixed interval (default 2.5s, hardcoded 1s on initial load). That works fine for the API surface, but it has two real costs:

  1. Constant per-tick allocation churn in any embedded webview. In keylightd-tray's case, we're a Wails app on webkit2gtk-4.1. Polling at 1Hz for hours has been observed to grow the cgroup memory of the tray process from ~140 MB at startup to ~1.2 GB after ~19 hours uptime (peak 2.2 GB). The Go heap stays ~3 MB; the bulk is webkit's JS engine accumulating GC pressure from sustained allocation. The tray polls the same way whether the user is interacting or the window has been hidden for hours.
  2. It's wasteful. The vast majority of poll responses report no state change. We're allocating, serializing, deserializing, hashing/diffing, and re-rendering for nothing.

A short-term frontend fix (pause polling when window hidden + diff-before-render) addresses ~95% of the observed memory growth, and is being applied separately. But the long-term shape is push-based.

Proposal

Add a Watch / Subscribe endpoint to pkg/client.ClientInterface, backed by a server-side broadcaster in the daemon. Clients subscribe once, receive an initial snapshot (or call GetLights first), and then receive deltas as events.

Interface sketch

// pkg/client/client.go
type ClientInterface interface {
    // ... existing methods ...

    // Watch subscribes to state-change events. Returns a channel that
    // delivers events until ctx is cancelled or the daemon connection
    // drops. Multiple concurrent watchers are supported.
    Watch(ctx context.Context) (<-chan StateEvent, error)
}

type StateEvent struct {
    Kind     EventKind  // LightAdded, LightRemoved, LightChanged, GroupAdded, GroupRemoved, GroupChanged, GroupLightsChanged
    LightID  string     // populated for Light* events
    GroupID  string     // populated for Group* events
    Light    *Light     // populated for LightAdded/LightChanged (nil for Removed)
    Group    *Group     // populated for GroupAdded/GroupChanged (nil for Removed)
}

type EventKind int
const (
    EventLightAdded EventKind = iota
    EventLightRemoved
    EventLightChanged
    EventGroupAdded
    EventGroupRemoved
    EventGroupChanged
    EventGroupLightsChanged
)

Implementation outline

  • Daemon side (internal/...): add a state-broadcaster that fans out change events to all subscribed clients. Light/group state mutators (the existing setters) fire events. Discovery/loss of physical lights also fires events.
  • Socket protocol: extend the existing Unix-socket request/response message types to include a streaming subscribe request that keeps the connection open and emits framed events. Or run a parallel notification socket if mixing streaming with request/response on the same connection is awkward.
  • HTTP/JSON path (pkg/client/http_client.go): expose the same via Server-Sent Events at e.g. GET /events so the HTTP transport keeps parity.
  • Client side: Watch() opens a streaming connection, demuxes events into the returned channel, handles reconnect with exponential backoff. On reconnect, a re-snapshot may be needed.
  • Versioning: bump the client/daemon protocol version. Older clients without Watch keep working unchanged.

Tray-side win

Once Watch exists, keylightd-tray does:

  1. GetLights() once at startup or window-show for the initial snapshot
  2. Watch() once, consume events into Wails' event bus via runtime.EventsEmit
  3. JS frontend subscribes to those events and updates DOM only on actual deltas

No polling. No timers. Active-state webkit memory growth becomes proportional to how much the user actually interacts, not how long the window has been open. Hidden-state memory growth is zero.

Estimated work

  • Daemon broadcaster + event types: ~150 LoC
  • Socket protocol extension: ~50 LoC (depends on existing framing)
  • Client Watch implementation + reconnect: ~80 LoC
  • HTTP SSE transport: ~60 LoC
  • Tests: ~100 LoC
  • Tray refactor to consume events: ~50 LoC

Roughly half a focused day end-to-end.

Out of scope for this issue

  • The frontend hide-pause + diff-render fix (being applied separately, doesn't need the daemon change)
  • Full eventing on every config field (settings, API keys etc.) — initial pass focuses on light + group state, which is what the tray actually polls today

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions