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:
- 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.
- 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:
GetLights() once at startup or window-show for the initial snapshot
Watch() once, consume events into Wails' event bus via runtime.EventsEmit
- 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
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:keylightd-tray's case, we're a Wails app onwebkit2gtk-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.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/Subscribeendpoint topkg/client.ClientInterface, backed by a server-side broadcaster in the daemon. Clients subscribe once, receive an initial snapshot (or callGetLightsfirst), and then receive deltas as events.Interface sketch
Implementation outline
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.subscriberequest 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.pkg/client/http_client.go): expose the same via Server-Sent Events at e.g.GET /eventsso the HTTP transport keeps parity.Watch()opens a streaming connection, demuxes events into the returned channel, handles reconnect with exponential backoff. On reconnect, a re-snapshot may be needed.Watchkeep working unchanged.Tray-side win
Once
Watchexists,keylightd-traydoes:GetLights()once at startup or window-show for the initial snapshotWatch()once, consume events into Wails' event bus viaruntime.EventsEmitNo 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
Watchimplementation + reconnect: ~80 LoCRoughly half a focused day end-to-end.
Out of scope for this issue