Skip to content

Replace the IPC server's 10ms poll with a blocking receive - #65

Closed
Magniquick wants to merge 1 commit into
psi4j:mainfrom
Magniquick:ipc-blocking-receive
Closed

Magniquick wants to merge 1 commit into
psi4j:mainfrom
Magniquick:ipc-blocking-receive

Conversation

@Magniquick

Copy link
Copy Markdown
Contributor

Written with an LLM. Tested on real hardware, not just compiled.

This is the second half of the idle-wakeup work after #64, which left the IPC
server thread as the process's remaining idle cost. It touches only
src/state/ipc/ and one call site in src/sunsetr.rs, and it does not depend
on #64, so it applies to main on its own.

Problem

IpcSocketServer::run loops on recv_timeout(10ms). The timeout is not for
the channel; it exists so the loop falls through to a non-blocking accept()
and to prune_clients() a hundred times a second, whether or not anything is
connected. Nothing on my machine subscribes to the socket, so that was about
100 wakeups per second for nothing.

Fix

The channel now carries a ServerMsg enum: Event(IpcEvent),
Client(UnixStream), or Shutdown. The listener moves to a new ipc-accept
thread that blocks in accept() and forwards each connection as Client, so
the server loop can do a plain blocking recv(), drain with try_recv(), then
prune. Both threads are 0 wakeups/s when idle.

IpcServer::shutdown() sends Shutdown before it joins. The accept thread
holds a sender, so the channel never disconnects by itself; the explicit
message is what ends the loop. The loop no longer watches the running flag,
because a thread blocked in recv() cannot observe an atomic and checking one
there would be illusory safety. IpcServer::start now creates the channel and
returns the notifier with it, so the call site shrinks to one line.

Behaviour changes worth knowing about:

  • The socket stays up until Core returns, so sunsetr status during the smooth
    shutdown transition now answers with the last applied state instead of
    failing. Nothing waits on the socket file; stop and restart wait on the
    lock file.
  • Accept errors are retried after 100ms rather than every 10ms. std retries
    EINTR internally, so what reaches that arm is EMFILE/ENFILE/ENOMEM/ENOBUFS.
    The kernel allocates the descriptor before dequeuing from the backlog, so a
    connection that hit EMFILE is still queued and is served on the next retry.
  • A subscriber that closes while the loop is blocked keeps its descriptors until
    the next message. The stale count is bounded by peak concurrent subscribers,
    since every new connection is itself a message and prunes the ones before it.

The accept thread is never joined. Once the loop drops the receiver, the next
accepted connection fails to send and the thread returns. It would leak a thread
and a descriptor per cycle if an in-process restart were added; today
ensure_single_instance runs before the server starts, so there is one per
process.

Two tests cover the shutdown path, with a bounded join so a hung loop fails the
test instead of wedging the run: Shutdown with no client ever connected, which
is the case a blocking receive could have deadlocked, and Shutdown with a
subscriber attached that has already read its registration snapshot. Both assert
the socket file is removed.

Tested

Arch, Hyprland, Intel Xe.

  • Whole process 0 wakeups/s over 20s across all threads, down from about 100/s
    with Replace hotplug polling with a registry watcher thread #64 alone and about 290/s on unpatched main. ipc-server and
    ipc-accept both 0.
  • DRM CTM read back from the kernel unchanged.
  • sunsetr status one-shot served in about 20ms, and the debug log shows the
    client pruned back to 0 on the next message.
  • A long-lived raw-socket subscriber received the initial snapshot plus live
    config_changed and state_applied events across two config reloads.
  • SIGTERM with that subscriber still attached: clean exit in about 200ms, socket
    file removed, join did not hang.
  • fmt and clippy clean; 237 tests.

Not exercised: a real EMFILE on the listener.

IpcSocketServer::run looped on recv_timeout(10ms). The timeout was not for
the channel; it existed so the loop fell through to a non-blocking accept()
and to prune_clients() a hundred times a second, whether or not anything
was connected. That was the whole idle cost of the ipc-server thread.

The channel now carries a ServerMsg: Event, Client or Shutdown. The
listener moves to an ipc-accept thread that blocks in accept() and forwards
each connection as Client, so the server loop can do a plain blocking
recv(), drain with try_recv(), then prune. Both threads are 0 wakeups per
second when idle.

IpcServer::shutdown() sends Shutdown before it joins. The accept thread
holds a sender, so the channel never disconnects by itself and the explicit
message is what ends the loop. The loop no longer watches the running flag:
a thread blocked in recv() cannot observe an atomic, so checking one there
would be illusory safety. IpcServer::start now creates the channel and
returns the notifier with it, which drops the sender accessor and shrinks
the call site to one line.

Accept errors are retried after 100ms instead of every 10ms. std retries
EINTR internally, so what reaches that arm means out of resources: EMFILE,
ENFILE, ENOMEM, ENOBUFS. Returning there would close the listener for the
rest of the process lifetime while the socket file stayed on disk, so every
later client would get ECONNREFUSED. The kernel allocates the descriptor
before dequeuing from the backlog, so a connection that hit EMFILE is still
queued and is served on the next retry.

Two consequences worth naming. The socket now stays up until Core returns,
so sunsetr status during the smooth shutdown transition answers with the
last applied state instead of failing; nothing waits on the socket file, as
stop and restart wait on the lock file. And a subscriber that closes while
the loop is blocked keeps its descriptors until the next message, so the
stale count is bounded by peak concurrent subscribers, since every new
connection is itself a message and prunes the ones before it.

Two tests cover the shutdown path, with a bounded join so a hung loop fails
the test instead of wedging the run: Shutdown with no client ever
connected, which is the case a blocking receive could have deadlocked, and
Shutdown with a subscriber attached that has already read its registration
snapshot. Both assert the socket file is removed.
Copilot AI lite review requested due to automatic review settings September 7, 2026 20:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The change achieves the stated idle-wakeup goal with a coherent design and adds targeted shutdown tests; only a minor documentation accuracy tweak was noted.

Pull request overview

This PR removes the IPC server thread’s periodic 10ms wakeups by restructuring the IPC server to block on a single channel receive, while moving socket accept() into a dedicated blocking accept thread. This aligns the IPC subsystem with the repo’s broader idle-wakeup reductions (following #64) by keeping both IPC threads idle when no clients or events exist.

Changes:

  • Introduce a ServerMsg enum so the server loop can block on recv() and handle events, new clients, and shutdown through one channel.
  • Move UnixListener::accept() to an ipc-accept thread that forwards new connections to the server loop as ServerMsg::Client.
  • Update the IPC startup API to return (IpcNotifier, IpcServer) from IpcServer::start(), and add tests that ensure shutdown unblocks a blocking recv() loop and removes the socket file.
File summaries
File Description
src/sunsetr.rs Simplifies the IPC startup call site to use the new IpcServer::start(debug_enabled) API and preserves shutdown behavior.
src/state/ipc/mod.rs Refactors IPC wiring to use ServerMsg, adds explicit Shutdown signaling, and adjusts IpcNotifier to wrap events.
src/state/ipc/server.rs Implements blocking recv() server loop, adds accept thread forwarding connections via ServerMsg, and adds shutdown-focused tests.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/state/ipc/server.rs
Comment on lines +129 to +133
/// Take the listener off the main loop's hands. The thread blocks in
/// `accept()` and is never joined. Once the server loop drops the receiver
/// the next accepted connection fails to send and the thread returns, so
/// this leaks nothing per process. It would leak a thread and a descriptor
/// per cycle if an in-process restart were ever added; today
@psi4j

psi4j commented Sep 15, 2026

Copy link
Copy Markdown
Owner

I reviewed and tested both #64 and #65. Good work. Thank you for your contribution and time invested.

@psi4j

psi4j commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Landed on main as 378deb0, rebased onto #64. Thanks!

@psi4j psi4j closed this Sep 15, 2026
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.

3 participants