Replace hotplug polling with a registry watcher thread - #64
Conversation
The main loop chopped every idle sleep into 10ms chunks so that poll_hotplug() could run. Signals never needed that, since recv_timeout returns as soon as one arrives; it existed only to notice new outputs. Each poll is a full Wayland roundtrip, so it woke the compositor too. Measured on one machine, idle at night with nothing happening, the main thread averaged about 190 voluntary context switches per second. Add a watcher thread that holds its own Wayland connection, binds only wl_registry, tracks which global names are wl_outputs, and sends HotplugCheck when one appears or disappears. The main loop consumes that inside its sleep, so it neither re-evaluates nor re-logs the schedule and the check consumes no sleep budget. Otherwise it sleeps its whole interval in one wait. Main-thread idle wakeups afterwards: 0 per second over 20 seconds, and the watcher is also 0 because it blocks in blocking_dispatch. Process total goes from about 290 to about 100 per second; the remainder is the IPC server thread's 10ms recv_timeout in src/state/ipc/server.rs, which this change does not touch. The watcher must not share the backend's connection. poll_hotplug() implementations call EventQueue::roundtrip(), which blocks reading the socket until its wl_display.sync callback arrives, and a second reader on the same connection can consume that callback and strand the roundtrip forever, hanging the poll and the shutdown behind it. HotplugMode replaces the implicit assumption that every backend needs polling. The hyprsunset backend never overrode poll_hotplug, so it was waking once per chunk to call an empty function; it now sleeps uninterrupted. The loop still calls poll_hotplug() once per iteration before sleeping, which covers what the watcher cannot: an output that arrives while the backend is still starting, and any HotplugCheck dropped while test mode or an interrupted reload had the loop occupied. If the watcher dies, from a compositor exit or a protocol error, its guard clears a liveness flag and sends a final HotplugCheck, so the loop warns once and falls back to a 1s poll instead of finishing a sleep that could run for hours with nothing watching. Tested on Hyprland and on wlr-gamma-control. The DRM CTM read back from the kernel is identical to the unpatched binary. Output add and remove are detected on both backends, with idle wakeups unchanged afterwards. Killing the compositor produces the fallback warning and sunsetr keeps running. Reloads, clean SIGTERM, 243 tests, fmt and clippy all pass.
There was a problem hiding this comment.
🔵 Needs a closer look
It introduces new cross-thread Wayland/event-loop behavior and alters main-loop sleep semantics in ways that are difficult to fully validate automatically.
Pull request overview
This PR removes frequent hotplug polling from the core sleep loop by introducing a dedicated Wayland registry watcher thread that notifies the main loop when wl_output globals appear/disappear, allowing uninterrupted sleeps while still reacting to output changes.
Changes:
- Added a
HotplugModebackend capability to declare how output hotplug should be detected (NotNeededvsWaylandRegistry). - Introduced a Wayland-registry-only watcher thread that sends a
SignalMessage::HotplugCheckto wake the main loop on output add/remove. - Updated signal draining and test-mode handling to intentionally drop
HotplugCheckto avoid re-emission/spin, with targeted unit tests.
File summaries
| File | Description |
|---|---|
| src/io/signals.rs | Adds HotplugCheck and updates drain behavior + tests to drop it during reload draining. |
| src/core/tests.rs | Adds unit tests for registry watcher state tracking and liveness guard behavior. |
| src/core/mod.rs | Implements watcher thread + registry dispatch, integrates watcher-driven wakeups into the main loop sleep logic with fallback polling. |
| src/commands/test.rs | Ensures HotplugCheck is ignored during test mode to prevent spinning; adds a unit test. |
| src/backend/wayland/mod.rs | Declares Wayland backend uses HotplugMode::WaylandRegistry. |
| src/backend/mod.rs | Introduces HotplugMode and a hotplug_mode() trait method for backends. |
| src/backend/hyprland/mod.rs | Declares Hyprland backend uses HotplugMode::WaylandRegistry. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
I've got a whole lot going on right now with University and work, but I'll take a look at these as soon as I can allocate some free time to this, hopefully some time later this week. |
same here :/ (hence the llms used). uni work is always a pita |
Written with an LLM. Tested on real hardware, not just compiled.
Problem
The main loop chops every idle sleep into 10ms chunks only so
poll_hotplug()runs. Signals never needed it, since
recv_timeoutreturns as soon as onearrives. Each poll is a full Wayland roundtrip, so it wakes the compositor too.
Idle at night on my machine, the main thread averaged about 190 voluntary
context switches per second with nothing happening.
Fix
A watcher thread holds its own Wayland connection, binds only
wl_registry,and sends
HotplugCheckwhen awl_outputglobal appears or disappears. Themain loop consumes that inside its sleep, so it does not re-evaluate or re-log
the schedule, and otherwise sleeps its whole interval in one wait.
Main-thread idle wakeups after: 0/s over 20s. The watcher is also 0/s, since it
blocks in
blocking_dispatch. Process total goes from about 290/s to about100/s; the remainder is the IPC server thread's 10ms
recv_timeoutinsrc/state/ipc/server.rs, which this PR does not touch.The watcher must not share the backend's connection.
poll_hotplug()callsEventQueue::roundtrip(), which blocks until itswl_display.synccallbackarrives, and a second reader can consume that callback and strand the roundtrip
forever. I hit this: the process hung and ignored SIGTERM.
HotplugModereplaces the implicit assumption that every backend polls. Thehyprsunset backend never overrode
poll_hotplug, so it was waking once perchunk to call an empty function. It now sleeps uninterrupted.
The loop still calls
poll_hotplug()once per iteration before sleeping, whichcovers what the watcher cannot: an output arriving while the backend is still
starting, and any
HotplugCheckdropped while test mode or an interruptedreload had the loop busy. If the watcher dies, its guard clears a liveness flag
and sends a final
HotplugCheck, so the loop warns once and falls back to a 1spoll instead of finishing a sleep that could run for hours unwatched.
Tested
Arch, Hyprland 0.56.2, Intel Xe.
modetest, identical to the unpatchedbinary at 3300K/90% gamma. Four reloads tracked exactly; stopping leaves
identity.
hyprctl output create headlessapplies the CTMto the new output,
removedrops it. Idle wakeups unchanged after both.swaymsg create_outputandoutput ... unplugboth detected.running.
Happy to split the
HotplugModechange out if you would rather take itseparately.