Skip to content
Merged
3 changes: 2 additions & 1 deletion .github/workflows/ci-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@ jobs:
echo "::warning::${kernel_modules_package} is unavailable; relying on the runner image kernel modules."
fi
sudo tee /etc/udev/rules.d/99-libvirtualhid-ci.rules >/dev/null <<'EOF'
SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ENV{HID_PHYS}=="libvirtualhid/uhid/*", MODE="0666", TAG+="uaccess"
SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{phys}=="libvirtualhid/uhid/*", MODE="0666", TAG+="uaccess"
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{phys}=="libvirtualhid/uhid/*", MODE="0666", TAG+="uaccess"
SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{name}=="(libvirtualhid)*", MODE="0666", TAG+="uaccess"
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="(libvirtualhid)*", MODE="0666", TAG+="uaccess"
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="libvirtualhid*", MODE="0666", TAG+="uaccess"
Expand Down
30 changes: 27 additions & 3 deletions docs/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,20 @@ and control channels. Numbered control-channel output is normalized before
parsing, whether the kernel includes the report number in the payload or
provides it separately on the UHID event.

The backend opens `/dev/uhid` in nonblocking mode, matching the original
asynchronous gamepad registration path. Its event reader is active before
device registration begins, and creation does not report success until the
kernel returns `UHID_START`. This keeps control-channel initialization
available throughout registration and prevents streaming hosts from publishing
a controller before its kernel HID device has started.

On Linux, DualShock 4 and DualSense emit Sony's native `Wireless Controller`
product name for Steam HID discovery. The requested USB or Bluetooth bus,
descriptor, and report framing remain unchanged; in particular, the default
DualShock 4 profile stays on its USB report contract. This transport-only name
is confined to the Linux backend; public profile names, Windows names, and VHF
behavior are unchanged.

Switch Pro keeps its Nintendo identity on the Linux uinput path. This follows
the evdev layout used by Linux-native virtual-controller implementations and
allows standard `FF_RUMBLE` effects without emulating the physical controller's
Expand Down Expand Up @@ -170,9 +184,19 @@ KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", GROUP="input
KERNEL=="uhid", GROUP="input", MODE="0660", TAG+="uaccess"
```

Consuming applications may also install name-matched rules for stable virtual
device names when generated `hidraw` or `input` nodes must be accessible to the
session user:
UHID gamepads use a stable `libvirtualhid/uhid/*` physical path even when the
library is compiled directly into a consuming application. Match that path for
generated `hidraw` and input event nodes because native profiles such as
DualShock 4 and DualSense intentionally do not retain the application's product
name:

```udev
KERNEL=="hidraw*", ATTRS{phys}=="libvirtualhid/uhid/*", GROUP="input", MODE="0660", TAG+="uaccess"
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{phys}=="libvirtualhid/uhid/*", GROUP="input", MODE="0660", TAG+="uaccess"
```

Consuming applications may additionally install name-matched rules for stable
virtual device names, including uinput-backed gamepads:

```udev
KERNEL=="hidraw*", ATTRS{name}=="Your App Controller*", GROUP="input", MODE="0660", TAG+="uaccess"
Expand Down
137 changes: 101 additions & 36 deletions src/platform/linux/uhid_backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include <cerrno>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstddef>
#include <cstdint>
#include <cstring>
Expand Down Expand Up @@ -99,6 +100,7 @@ namespace lvh::detail {
#if defined(__linux__)
namespace ps = playstation_feature_reports;
constexpr auto playstation_periodic_report_ms = 10;
constexpr auto uhid_start_timeout = std::chrono::seconds {5};
#endif

int system_access(const char *path, int mode) {
Expand Down Expand Up @@ -303,6 +305,16 @@ namespace lvh::detail {
}
return to_uhid_bus(profile.bus_type);
}

std::string_view uhid_gamepad_name(const DeviceProfile &profile) {
// Steam's PlayStation HID path expects Sony's native product name. Keep
// consumer branding out of the Linux transport identity while preserving
// the requested descriptor, bus, and report framing.
if (is_playstation_profile(profile.gamepad_kind)) {
return "Wireless Controller";
}
return profile.name;
}
#endif

std::uint16_t to_uinput_bus(BusType bus_type) {
Expand Down Expand Up @@ -2787,7 +2799,8 @@ namespace lvh::detail {
}
physical_id_ = std::format("libvirtualhid/uhid/{}", id);

copy_string(request.name, options.profile.name);
device_name_ = uhid_gamepad_name(options.profile);
copy_string(request.name, device_name_);
copy_string(request.phys, physical_id_);
copy_string(request.uniq, unique_id_);
request.rd_size = static_cast<std::uint16_t>(options.profile.report_descriptor.size());
Expand All @@ -2797,20 +2810,31 @@ namespace lvh::detail {
request.version = options.profile.version;
std::memcpy(request.rd_data, options.profile.report_descriptor.data(), options.profile.report_descriptor.size());
profile_ = options.profile;
device_name_ = options.profile.name;
{
std::lock_guard lock {report_mutex_};
last_report_ = reports::pack_input_report(profile_, {});
}

if (const auto status = write_event(event); !status.ok()) {
return status;
{
std::lock_guard lock {lifecycle_mutex_};
started_ = false;
reader_exited_ = false;
}

running_ = true;
reader_ = std::jthread {[this](std::stop_token stop_token) {
read_loop(stop_token);
}};

if (const auto status = write_event(event); !status.ok()) {
stop_reader();
return status;
}

if (const auto status = wait_for_start(); !status.ok()) {
stop_reader();
return status;
}

if (is_playstation_profile(profile_.gamepad_kind)) {
periodic_reporter_ = std::jthread {[this](std::stop_token stop_token) {
periodic_report_loop(stop_token);
Expand Down Expand Up @@ -2914,47 +2938,65 @@ namespace lvh::detail {
return OperationStatus::success();
}

void read_loop(std::stop_token stop_token) {
while (!stop_token.stop_requested() && running_) {
pollfd descriptor {};
descriptor.fd = fd_;
descriptor.events = POLLIN;
enum class ReadEventResult {
event,
retry,
stop,
};

const auto result = system_poll(&descriptor, 1, poll_timeout_ms);
if (result < 0) {
if (errno == EINTR) {
continue;
}
return;
}
if (result == 0) {
continue;
}
if ((descriptor.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
return;
}
if ((descriptor.revents & POLLIN) == 0) {
continue;
}
ReadEventResult read_event(uhid_event &event) const {
pollfd descriptor {};
descriptor.fd = fd_;
descriptor.events = POLLIN;

const auto result = system_poll(&descriptor, 1, poll_timeout_ms);
if (result < 0) {
return errno == EINTR ? ReadEventResult::retry : ReadEventResult::stop;
}
if ((descriptor.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
return ReadEventResult::stop;
}
if (result == 0 || (descriptor.revents & POLLIN) == 0) {
return ReadEventResult::retry;
}

const auto result_read = system_read(fd_, std::as_writable_bytes(std::span {&event, 1U}));
if (result_read < 0) {
return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR ?
ReadEventResult::retry :
ReadEventResult::stop;
}
return result_read == 0 ? ReadEventResult::stop : ReadEventResult::event;
}

void read_loop(std::stop_token stop_token) {
while (!stop_token.stop_requested() && running_) {
uhid_event event {};
const auto read_result = system_read(fd_, std::as_writable_bytes(std::span {&event, 1U}));
if (read_result < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) {
continue;
}
return;
const auto result = read_event(event);
if (result == ReadEventResult::stop) {
break;
}
if (read_result == 0) {
return;
if (result == ReadEventResult::event) {
handle_event(event);
}
}

handle_event(event);
{
std::lock_guard lock {lifecycle_mutex_};
reader_exited_ = true;
}
lifecycle_condition_.notify_all();
}

void handle_event(const uhid_event &event) {
switch (event.type) {
case UHID_START:
{
std::lock_guard lock {lifecycle_mutex_};
started_ = true;
}
lifecycle_condition_.notify_all();
break;
case UHID_OUTPUT:
dispatch_output_report(event.u.output.data, event.u.output.size);
break;
Expand All @@ -2970,6 +3012,25 @@ namespace lvh::detail {
}
}

void stop_reader() {
running_ = false;
if (reader_.joinable()) {
reader_.request_stop();
reader_.join();
}
}

OperationStatus wait_for_start() {
if (std::unique_lock lock {lifecycle_mutex_}; !lifecycle_condition_.wait_for(lock, uhid_start_timeout, [this]() {
return started_ || reader_exited_;
})) {
return OperationStatus::failure(ErrorCode::backend_failure, "timed out waiting for UHID_START");
} else if (!started_) {
return OperationStatus::failure(ErrorCode::backend_failure, "UHID reader stopped before UHID_START");
}
return OperationStatus::success();
}

void periodic_report_loop(std::stop_token stop_token) {
while (!stop_token.stop_requested() && running_) {
std::this_thread::sleep_for(std::chrono::milliseconds {playstation_periodic_report_ms});
Expand Down Expand Up @@ -3113,6 +3174,10 @@ namespace lvh::detail {
std::atomic_bool running_ = false;
std::jthread reader_;
std::jthread periodic_reporter_;
std::mutex lifecycle_mutex_;
std::condition_variable lifecycle_condition_;
bool started_ = false;
bool reader_exited_ = false;
std::mutex write_mutex_;
std::mutex report_mutex_;
std::mutex callback_mutex_;
Expand Down Expand Up @@ -3185,7 +3250,7 @@ namespace lvh::detail {
}

#if defined(__linux__)
const auto fd = system_open(uhid_path, O_RDWR | O_CLOEXEC);
const auto fd = system_open(uhid_path, O_RDWR | O_CLOEXEC | O_NONBLOCK);
if (fd < 0) {
return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uhid", errno), nullptr};
}
Expand Down
60 changes: 51 additions & 9 deletions tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,41 @@ namespace lvh::detail::test {
std::uint64_t remaining = 0;
};

/**
* @brief UHID device-creation observations captured by a socketpair peer.
*/
struct LinuxUhidCreationObservation {
/**
* @brief Whether the peer observed a create event.
*/
bool saw_create = false;

/**
* @brief Whether create remained pending until the peer sent UHID_START.
*/
bool waited_for_start = false;

/**
* @brief Product name carried by the observed create event.
*/
std::string name;
};

/**
* @brief UHID output callback observations captured during a round trip.
*/
struct LinuxUhidOutputObservation {
/**
* @brief Number of output callbacks received.
*/
std::size_t callback_count = 0;

/**
* @brief Last output callback payload.
*/
GamepadOutput last;
};

/**
* @brief Result from a socketpair-backed UHID lifecycle test.
*/
Expand All @@ -122,9 +157,9 @@ namespace lvh::detail::test {
OperationStatus close_status;

/**
* @brief Whether the peer observed a create event.
* @brief Device-creation observations.
*/
bool saw_create = false;
LinuxUhidCreationObservation creation;

/**
* @brief Whether the peer observed an input report event.
Expand Down Expand Up @@ -186,6 +221,11 @@ namespace lvh::detail::test {
*/
bool saw_dualshock4_bluetooth_input = false;

/**
* @brief Whether the peer observed a USB-framed DualShock 4 input report.
*/
bool saw_dualshock4_usb_input = false;

/**
* @brief Whether the peer observed a set-report reply.
*/
Expand All @@ -197,14 +237,9 @@ namespace lvh::detail::test {
bool saw_destroy = false;

/**
* @brief Number of output callbacks received.
*/
std::size_t output_callback_count = 0;

/**
* @brief Last output callback payload.
* @brief Output callback observations.
*/
GamepadOutput last_output;
LinuxUhidOutputObservation output;
};

/**
Expand Down Expand Up @@ -830,6 +865,13 @@ namespace lvh::detail::test {
*/
OperationStatus linux_backend_gamepad_fake_open_failure();

/**
* @brief Capture the flags used to open UHID for a descriptor-driven gamepad.
*
* @return Flags passed to `open()` for `/dev/uhid`.
*/
int linux_backend_gamepad_open_flags();

/**
* @brief Try creating a Linux backend gamepad while fake UHID creation fails.
*
Expand Down
Loading
Loading