Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/vmrun-libusb.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,23 @@ modprobe dummy_hcd || true
modprobe raw_gadget || true
ls -l /dev/raw-gadget || true

ctest --test-dir build --output-on-failure
timeout --kill-after=3s 130s ctest --test-dir build \
--output-on-failure --output-junit "$PWD/build/libusb-tests.xml"
rc=$?
if [ "$rc" -eq 0 ]; then
python3 - <<'PY'
import sys
import xml.etree.ElementTree as ET

cases = ET.parse("build/libusb-tests.xml").getroot().findall(".//testcase")
expected = {"DeviceIO_libusb", "ReadInterrupt_libusb"}
passed = {case.attrib["name"] for case in cases
if not any(case.find(tag) is not None for tag in ("skipped", "failure", "error"))}
if not expected <= passed:
sys.exit("Both libusb virtual-device suites must run and pass without skips.")
PY
rc=$?
fi

echo "=== diag ==="
lsmod | grep -E "raw_gadget|dummy_hcd|udc" || true
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/builds.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ jobs:
run: |
# The macOS virtual device needs the com.apple.developer.hid.virtual.device
# entitlement and interactive user consent, neither available on a hosted
# runner, so DeviceIO_darwin self-skips (CTest code 77). This still
# verifies the provider builds and the test runs/links.
# runner, so both darwin suites self-skip (CTest code 77). This still
# verifies the provider builds and the tests run/link.
ASAN_OPTIONS=detect_leaks=0 ctest --output-on-failure
- name: Check artifacts
uses: andstor/file-existence-action@v2
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/libusb-vhid-test.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: Linux libusb Virtual HID Device Test (manual)

# Runs the device-I/O test against the HIDAPI *libusb* backend using a real
# Runs device-I/O and read-interrupt tests against the HIDAPI libusb backend using a
# virtual USB HID device (USB Raw Gadget on top of dummy_hcd).
#
# Ubuntu 24.04 supplies a generic guest kernel and matching modules-extra
Expand Down Expand Up @@ -34,7 +34,7 @@ jobs:
printf '%s\n' 'https://archive.ubuntu.com/ubuntu' | sudo tee /etc/apt/apt-mirrors.txt
sudo apt update
sudo apt install -y --no-install-recommends \
build-essential cmake libudev-dev libusb-1.0-0-dev \
build-essential cmake libudev-dev libusb-1.0-0-dev python3 \
qemu-system-x86 virtme-ng linux-image-generic
# raw_gadget ships in the generic kernel's modules-extra; install that
# plus headers (needed to build dummy_hcd, which Ubuntu doesn't package).
Expand Down Expand Up @@ -64,7 +64,7 @@ jobs:
-DHIDAPI_WITH_LIBUSB=ON -DHIDAPI_WITH_HIDRAW=OFF -DHIDAPI_WITH_TESTS=ON
timeout --kill-after=3s 300s cmake --build build

- name: Run DeviceIO_libusb inside a VM (generic kernel + raw_gadget)
- name: Run libusb virtual-device tests inside a VM (generic kernel + raw_gadget)
run: |
set -eux
# The generic kernel just installed (has dummy_hcd + raw_gadget modules).
Expand All @@ -79,11 +79,11 @@ jobs:
sudo chmod 666 /dev/kvm 2>/dev/null || true
# Boot that kernel in a VM (KVM if /dev/kvm is usable, else TCG). The
# guest runs as root with the host fs mounted; load the gadget modules
# and run the test against the host build tree. vng exit-code
# and run both suites against the host build tree. vng exit-code
# propagation varies, so derive pass/fail from a sentinel line.
# Boot the kernel in a VM and run the guest script via --exec (vng's
# native flag; passing a complex command after '--' gets mangled). The
# script loads dummy_hcd/raw_gadget and runs the test (see it for why).
# script loads dummy_hcd/raw_gadget and checks both suites without skips.
timeout --kill-after=3s 300s vng -v --pwd -r "${KIMG}" --exec "bash hidapisrc/.github/vmrun-libusb.sh" 2>&1 | tee vng.log
echo "--- VM run result ---"
grep -q "VNG_CTEST_EXIT=0" vng.log
18 changes: 15 additions & 3 deletions .github/workflows/win-vhid-test.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Windows Virtual HID Device Test (manual)

# Builds and self-signs a modified vhidmini2 UMDF2 driver, installs it on a
# hosted runner, then runs the backend-agnostic device-I/O test against that
# hosted runner, then runs device-I/O and read-interrupt tests against that
# real virtual HID device (winapi backend). It installs a driver, so it is not
# part of the per-push CI matrix; run it on demand from the Actions tab, or by
# adding the 'ci-virtual-device' label to a pull request.
Expand Down Expand Up @@ -141,11 +141,23 @@ jobs:
cmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DHIDAPI_WITH_TESTS=ON
cmake --build build --config Release

- name: Run device-I/O test against the virtual device
- name: Run device-I/O and read-interrupt tests against the virtual device
shell: pwsh
working-directory: build
run: |
ctest -C Release -R DeviceIO_winapi --output-on-failure
ctest -C Release -R '^(DeviceIO|ReadInterrupt)_winapi$' --no-tests=error --output-on-failure --output-junit vdev-results.xml
if ($LASTEXITCODE -ne 0) { throw "Virtual-device tests failed." }
[xml]$results = Get-Content vdev-results.xml -Raw
$cases = @($results.SelectNodes('//testcase'))
if ($cases.Count -ne 2) { throw "Expected both Windows virtual-device suites." }
foreach ($name in @('DeviceIO_winapi', 'ReadInterrupt_winapi')) {
if ($name -notin $cases.name) { throw "Missing virtual-device suite: $name" }
}
foreach ($case in $cases) {
if ($null -ne $case.SelectSingleNode('skipped|failure|error')) {
throw "Virtual-device suite did not pass: $($case.name)"
}
}

- name: Cleanup virtual device
if: always()
Expand Down
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ generate example/test code that respects it. The authoritative reference is
- Since v0.15.0, a dedicated read thread using only `hid_read`, `hid_read_timeout` and
`hid_read_error` is safe alongside other operations on the same device from another thread.
Do not use `hid_error` from such a thread; use `hid_read_error`.
- Since v0.16.0, `hid_read_interrupt` can wake a blocked read from another thread.
Calls to `hid_read_interrupt`, `hid_is_read_interrupted` and `hid_read_clear_interrupt`
on the same device must be serialized against one another, including repeated calls to
the same function. Keep the device alive until every call using it has returned; do not
overlap these functions with `hid_close` or `hid_exit`. Clear only after the read returns
when deterministic read-interruption behavior is required.
- macOS: `hid_init()` and `hid_exit()` must be called from the same thread, and that thread must
stay alive until all devices are closed and `hid_exit()` has completed.

Expand Down
98 changes: 70 additions & 28 deletions freebsd/hid.c
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ struct hid_device_ {
wchar_t *read_error_str;
const char *device_path;
struct hid_device_info* device_info;
int interrupt_pipe[2];
int interrupted;
};

static hid_device *new_hid_device(void)
Expand All @@ -54,6 +56,14 @@ static hid_device *new_hid_device(void)
dev->error_str = NULL;
dev->read_error_str = NULL;
dev->device_info = NULL;
dev->interrupt_pipe[0] = -1;
dev->interrupt_pipe[1] = -1;
if (pipe2(dev->interrupt_pipe, O_CLOEXEC | O_NONBLOCK) < 0) {
int saved_errno = errno;
free(dev);
errno = saved_errno;
return NULL;
}

return dev;
}
Expand Down Expand Up @@ -878,15 +888,14 @@ hid_device * HID_API_EXPORT hid_open_path(const char *path)

dev = new_hid_device();
if (!dev) {
errno = ENOMEM;
register_global_error("Couldn't allocate memory");
register_global_error_format("Failed to create hid_device: %s", strerror(errno));
return NULL;
}

dev->device_path = strdup(path);
if (!dev->device_path) {
free(dev);
register_global_error("Couldn't allocate memory");
hid_close(dev);
return NULL;
}
dev->device_handle = open(path, O_RDWR | O_CLOEXEC);
Expand All @@ -907,8 +916,7 @@ hid_device * HID_API_EXPORT hid_open_path(const char *path)
else {
/* Unable to open a device. */
register_global_error_format("Failed to open a device with path '%s': %s", path, strerror(errno));
free((void *)dev->device_path);
free(dev);
hid_close(dev);
return NULL;
}
}
Expand Down Expand Up @@ -945,20 +953,22 @@ int HID_API_EXPORT hid_read_timeout(hid_device *dev, unsigned char *data, size_t

int bytes_read;

if (milliseconds >= 0) {
/* Milliseconds is either 0 (non-blocking) or > 0 (contains
a valid timeout). In both cases we want to call poll()
and wait for data to arrive. Don't rely on non-blocking
operation (O_NONBLOCK) since some kernels don't seem to
properly report device disconnection through read() when
in non-blocking mode. */
{
/* Poll both descriptors even for an indefinite read, so an
interrupt can wake the caller without device input. The wake
pipe stays readable until hid_read_clear_interrupt(), and it is
checked before reading the device, so an interrupted read fails
even when input is queued. */
int ret;
struct pollfd fds;

fds.fd = dev->device_handle;
fds.events = POLLIN;
fds.revents = 0;
ret = poll(&fds, 1, milliseconds);
struct pollfd fds[2];

fds[0].fd = dev->device_handle;
fds[0].events = POLLIN;
fds[0].revents = 0;
fds[1].fd = dev->interrupt_pipe[0];
fds[1].events = POLLIN;
fds[1].revents = 0;
ret = poll(fds, 2, milliseconds);
if (ret == 0) {
/* Timeout */
return ret;
Expand All @@ -968,15 +978,15 @@ int HID_API_EXPORT hid_read_timeout(hid_device *dev, unsigned char *data, size_t
register_error_str(&dev->read_error_str, strerror(errno));
return ret;
}
else {
/* Check for errors on the file descriptor. This will
indicate a device disconnection. */
if (fds.revents & (POLLERR | POLLHUP | POLLNVAL)) {
// We cannot use strerror() here as no -1 was returned from poll().
errno = EIO;
register_error_str(&dev->read_error_str, "hid_read_timeout: unexpected poll error (device disconnected)");
return -1;
}
/* Device disconnection takes priority over an interrupt. */
if (fds[0].revents & (POLLERR | POLLHUP | POLLNVAL)) {
errno = EIO;
register_error_str(&dev->read_error_str, "hid_read_timeout: unexpected poll error (device disconnected)");
return -1;
}
if (fds[1].revents & POLLIN) {
register_error_str(&dev->read_error_str, "hid_read_timeout: operation interrupted");
return -1;
}
}

Expand Down Expand Up @@ -1013,6 +1023,33 @@ int HID_API_EXPORT hid_set_nonblocking(hid_device *dev, int nonblock)
return 0; /* Success */
}

void HID_API_EXPORT hid_read_interrupt(hid_device *dev)
{
__atomic_store_n(&dev->interrupted, 1, __ATOMIC_RELEASE);
const char one = 1;
ssize_t written;
do {
written = write(dev->interrupt_pipe[1], &one, sizeof(one));
} while (written < 0 && errno == EINTR);
/* EAGAIN means an interrupt notification is already queued. */
}

int HID_API_EXPORT hid_is_read_interrupted(hid_device *dev)
{
return __atomic_load_n(&dev->interrupted, __ATOMIC_ACQUIRE);
}

void HID_API_EXPORT hid_read_clear_interrupt(hid_device *dev)
{
char buf[64];
ssize_t got;
do {
got = read(dev->interrupt_pipe[0], buf, sizeof(buf));
} while (got > 0 || (got < 0 && errno == EINTR));
/* Publish the cleared state only after the wake pipe is drained. */
__atomic_store_n(&dev->interrupted, 0, __ATOMIC_RELEASE);
}


int HID_API_EXPORT hid_send_feature_report(hid_device *dev, const unsigned char *data, size_t length)
{
Expand Down Expand Up @@ -1166,7 +1203,12 @@ void HID_API_EXPORT hid_close(hid_device *dev)
if (!dev)
return;

close(dev->device_handle);
if (dev->device_handle >= 0)
close(dev->device_handle);
if (dev->interrupt_pipe[0] >= 0)
close(dev->interrupt_pipe[0]);
if (dev->interrupt_pipe[1] >= 0)
close(dev->interrupt_pipe[1]);

free((void *)dev->device_path);
free(dev->error_str);
Expand Down
102 changes: 102 additions & 0 deletions hidapi/hidapi.h
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,108 @@ extern "C" {
*/
int HID_API_EXPORT HID_API_CALL hid_set_nonblocking(hid_device *dev, int nonblock);

/** @brief Asynchronously interrupt blocking hid_read/hid_read_timeout call.

Since version 0.16.0, @ref HID_API_VERSION >= HID_API_MAKE_VERSION(0, 16, 0)

Puts the read pipeline of @p dev into a sticky interrupted state
and wakes a hid_read()/hid_read_timeout() call that is waiting
for input. An interrupted read returns -1; hid_read_error(dev)
returns a string indicating the read was interrupted.

A read already in progress when this function is called may
still return data that was already buffered or queued. Every
read entered after this function returns fails immediately with
-1, until @ref hid_read_clear_interrupt is called. Queued input
is not discarded; it remains readable after the interrupt is
cleared.

Only the read pipeline is affected: hid_write(), hid_get_input_report(),
feature/output report functions, and all other operations on @p dev
continue to work normally.

This function may run concurrently with device I/O, including a
blocked read, but not with hid_close() or hid_exit(). The device
handle must remain valid until all calls using it have returned.
The call is idempotent and is safe when no read is in flight.

Calls to hid_read_interrupt(), hid_is_read_interrupted(), and
hid_read_clear_interrupt() on the same device must be serialized
against one another, including calls to the same function.
Otherwise, behavior is undefined.

After this function returns, hid_is_read_interrupted() returns 1
until the interrupt is cleared. This function has no failure
result; it relies on the operating system and device driver to
support read interruption.

Recommended usage to cleanly shut down a dedicated read thread:
@code
hid_read_interrupt(dev);
// join the read thread
hid_close(dev);
@endcode

@ingroup API
@param dev A device handle returned from hid_open().
*/
void HID_API_EXPORT HID_API_CALL hid_read_interrupt(hid_device *dev);

/** @brief Query whether the read pipeline is currently interrupted.

Since version 0.16.0, @ref HID_API_VERSION >= HID_API_MAKE_VERSION(0, 16, 0)

Returns the current interrupt state set by @ref hid_read_interrupt
and @ref hid_read_clear_interrupt. This function may run alongside
device I/O, but not hid_close() or hid_exit(). The device handle
must remain valid until the call returns.

Calls to hid_read_interrupt(), hid_is_read_interrupted(), and
hid_read_clear_interrupt() on the same device must be serialized
against one another, including calls to the same function.
Otherwise, behavior is undefined.

@ingroup API
@param dev A device handle returned from hid_open().

@returns
1 if the read pipeline is interrupted, 0 if not.
*/
int HID_API_EXPORT HID_API_CALL hid_is_read_interrupted(hid_device *dev);

/** @brief Clear the read-interrupt state, allowing reads to resume.

Since version 0.16.0, @ref HID_API_VERSION >= HID_API_MAKE_VERSION(0, 16, 0)

Clears the sticky state set by @ref hid_read_interrupt. After
this function returns, hid_is_read_interrupted() returns 0 and
reads entered afterwards operate normally. Calling it when the
read pipeline is not interrupted has no effect.

Any data that the device buffered during the interrupted period
may be returned by subsequent reads, subject to a
(backend-specific) buffer capacity. For a fresh-start behavior,
the caller may drain the buffered data with a loop of
hid_read_timeout(dev, ..., 0) calls before resuming the normal
read loop.

Recommended use: call only when no hid_read*() call is in flight
on @p dev. A read that is in flight while this function runs may
either fail as interrupted or continue normally; which of the two
happens is indeterminate.
Do not overlap this function with hid_close() or hid_exit(); the
device handle must remain valid until the call returns.

Calls to hid_read_interrupt(), hid_is_read_interrupted(), and
hid_read_clear_interrupt() on the same device must be serialized
against one another, including calls to the same function.
Otherwise, behavior is undefined.

@ingroup API
@param dev A device handle returned from hid_open().
*/
void HID_API_EXPORT HID_API_CALL hid_read_clear_interrupt(hid_device *dev);

/** @brief Send a Feature report to the device.

Feature reports are sent over the Control endpoint as a
Expand Down
Loading
Loading