Skip to content

Fold InputReader into the loop and make UPower construction async - #23

Merged
jwinarske merged 2 commits into
mainfrom
jw/event-loop-inputreader
Jul 12, 2026
Merged

Fold InputReader into the loop and make UPower construction async#23
jwinarske merged 2 commits into
mainfrom
jw/event-loop-inputreader

Conversation

@jwinarske

Copy link
Copy Markdown
Owner

Final increment of the event-loop source contract, on top of #22. The BlueZ controllers now run entirely on the single EventLoop thread — no per-device threads and no in-callback blocking D-Bus calls remain.

InputReader is now an EventSource

  • The device is opened and its descriptors / feature reports are read in the constructor (non-blocking fd); dispatch() reads and decodes one report per readable event. valid() reports whether initialisation succeeded.
  • Its worker thread, stop-eventfd and private epoll are gone. The controller add()s the reader to the loop on device arrival (only when valid()) and retire()s it on removal.

EventLoop::retire()

New primitive: unregisters a source and destroys it at a safe point (after the current dispatch pass). This closes a real use-after-free hazard — a udev "remove" and the reader's own fd (POLLHUP) can be ready in the same poll(), so a handler that reset the reader mid-pass could free an fd still referenced by this iteration's poll set. retire() holds ownership until the next apply_pending().

UPower construction is now async

  • UPowerClient fetches properties via GetAllAsync and enumerates devices via callMethodAsync; UPowerDisplayDevice fetches via GetAllAsync. So when a controller with a battery connects, the loop thread is no longer blocked on those round-trips. Pending calls are cancelled by unregisterProxy() if the object is destroyed first.
  • UPowerClient keeps its devices_mutex_: it's also used by the standalone src/upower example, which still runs on the connection's own async event-loop thread.

Net −148 lines (the reader threads/eventfds deleted).

Validation

  • Runtime (verified): clients start, the loop drives D-Bus callbacks, the async UPower GetAll reply is delivered (onPropertiesChanged: org.freedesktop.UPower logged by the standalone upower_client against live UPower), and SIGTERM shuts down cleanly (exit 0). No regression in the standalone upower shutdown.
  • HID input data path: build-verified only — decoding a real report stream needs a physical PS5/Xbox/HoriPad controller, which isn't available in this environment. The decode logic is unchanged from the previous threaded version; only where it runs (loop dispatch() vs a thread) changed.
  • clang-format --Werror and clang-tidy-19 clean; full build green (gcc/clang). Two NOLINT(performance-unnecessary-value-param) mark the sdbus-mandated by-value error argument (a const-ref signature fails to compile via function_traits).

Stacked on #22 (now merged); rebased onto main.

Contract complete

With this, the scoped fd() + dispatch() source contract is fully realized for BlueZ: D-Bus, udev, HID input, and signals all run on one EventLoop thread, and every controller mutex introduced back in #18 is gone. The ~14 pure-D-Bus clients still use enterEventLoopAsync() + monitorLoop() — migrating them was called out as optional/low-value in the scoping and is not included.

Completes the BlueZ event-loop migration: the controllers now run entirely on
the single EventLoop thread with no per-device threads and no in-callback
blocking D-Bus calls.

InputReader is now an EventSource:
- The device is opened and its descriptors/feature reports are read in the
  constructor (non-blocking fd); dispatch() reads and decodes one report per
  readable event. valid() reports whether initialisation succeeded.
- Its worker thread, stop eventfd and private epoll are gone. The controllers
  add the reader to the loop on device arrival and retire it on removal.

EventLoop::retire() added: unregisters a source and destroys it at a safe point
(after the current dispatch pass), so a udev "remove" handler can drop the
reader for the device that just went away without a use-after-free on an fd
still in the current poll set.

UPower construction is now asynchronous:
- UPowerClient fetches properties via GetAllAsync and enumerates devices via
  callMethodAsync; UPowerDisplayDevice fetches properties via GetAllAsync. The
  single loop thread is no longer blocked by these round-trips when a
  controller with a battery connects. Pending calls are cancelled by
  unregisterProxy() if the object is destroyed first. UPowerClient keeps its
  mutex because it is also used by the standalone upower example, which still
  runs on the connection's own async event-loop thread.

Verified at runtime: the clients start, the loop drives the D-Bus callbacks,
the async UPower GetAll reply is delivered (onPropertiesChanged fires), and
SIGTERM shuts down cleanly. The HID input data path needs real controller
hardware and is build-verified only.

Signed-off-by: Joel Winarske <joel.winarske@linux.com>
Adversarial review of the event-loop work surfaced two high-severity bugs and
several smaller issues:

- POLLHUP busy-spin: poll() reports POLLHUP/POLLERR level-triggered and
  unmaskable, so a hung-up device fd stayed "ready" every iteration and
  InputReader::dispatch() returned early without reading or removing it —
  spinning the loop at ~100% CPU until the udev "remove" event arrived (and
  unbounded if it never did). EventLoop::run() now stops polling a source as
  soon as its dispatch reports POLLHUP/POLLERR. Covered by a new regression
  test (a closed pipe reproduces the hangup deterministically).

- Use-after-free in apply_pending(): a source added and retired within the same
  iteration had its object destroyed via to_retire_ and then its dangling
  pointer re-inserted via to_add_. Adds now skip any source also scheduled for
  removal that round, and retired objects are destroyed last.

- stop() now uses release/acquire ordering for running_/exit_code_ instead of
  relaxed.

- UPowerClient / UPowerDisplayDevice: restore the try/catch around property
  parsing that the sync->async conversion dropped, so a Variant type mismatch
  cannot escape the async reply slot.

- xbox main: construct SignalSource before createSystemBusConnection, matching
  ps5/horipad and the "block signals before any thread starts" invariant.

- horipad InputReader: clamp the report memcpy to bytes_read (like ps5/xbox)
  rather than buffer.size().

- SignalSource: unblock the signal mask if signalfd() fails, so the process is
  not left unkillable via SIGINT/SIGTERM.

Signed-off-by: Joel Winarske <joel.winarske@linux.com>
@jwinarske
jwinarske merged commit 70cd4d2 into main Jul 12, 2026
6 checks passed
@jwinarske
jwinarske deleted the jw/event-loop-inputreader branch July 12, 2026 22:59
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