diff --git a/packages/native-linux-uinput/package.json b/packages/native-linux-uinput/package.json index 7b21b17..2419761 100644 --- a/packages/native-linux-uinput/package.json +++ b/packages/native-linux-uinput/package.json @@ -49,6 +49,7 @@ "files": [ "dist", "src/helper/opencontroller-uinput-bridge.c", + "src/helper/uinput-open.h", "README.md", "LICENSE" ], diff --git a/packages/native-linux-uinput/src/__tests__/linux-uinput.test.ts b/packages/native-linux-uinput/src/__tests__/linux-uinput.test.ts index 780ac18..6590d41 100644 --- a/packages/native-linux-uinput/src/__tests__/linux-uinput.test.ts +++ b/packages/native-linux-uinput/src/__tests__/linux-uinput.test.ts @@ -103,6 +103,10 @@ describe("linux uinput adapter helpers", () => { test("helper source advertises Linux force-feedback rumble", async () => { const source = await readFile(linuxUinputHelperSourcePath, "utf8"); + const openSource = await readFile( + new URL("../helper/uinput-open.h", import.meta.url), + "utf8", + ); expect(source).toContain("UI_SET_EVBIT, EV_FF"); expect(source).toContain("UI_SET_FFBIT, FF_RUMBLE"); @@ -111,7 +115,7 @@ describe("linux uinput adapter helpers", () => { expect(source).toContain("UI_END_FF_UPLOAD"); expect(source).toContain("EV_UINPUT"); expect(source).toContain("opencontroller.bridge.feedback"); - expect(source).toContain("O_RDWR | O_NONBLOCK"); + expect(openSource).toContain("O_RDWR | O_NONBLOCK"); }); test("helper source advertises Linux player LED feedback", async () => { diff --git a/packages/native-linux-uinput/src/__tests__/uinput-open.test.ts b/packages/native-linux-uinput/src/__tests__/uinput-open.test.ts new file mode 100644 index 0000000..fe66b37 --- /dev/null +++ b/packages/native-linux-uinput/src/__tests__/uinput-open.test.ts @@ -0,0 +1,46 @@ +import { describe, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +describe("native uinput open retry helper", () => { + const nativeTest = process.platform === "linux" ? test : test.skip; + + nativeTest("passes deterministic bounded retry cases", async () => { + const temporaryDirectory = await mkdtemp( + join(tmpdir(), "opencontroller-uinput-open-test-"), + ); + const binaryPath = join(temporaryDirectory, "uinput-open-test"); + const testSourcePath = fileURLToPath( + new URL("../helper/uinput-open-test.c", import.meta.url), + ); + const helperDirectory = fileURLToPath( + new URL("../helper/", import.meta.url), + ); + + try { + execFileSync( + "cc", + [ + "-std=c11", + "-D_DEFAULT_SOURCE", + "-Wall", + "-Wextra", + "-Werror", + "-Wno-unused-function", + "-I", + helperDirectory, + testSourcePath, + "-o", + binaryPath, + ], + { stdio: "pipe" }, + ); + execFileSync(binaryPath, [], { stdio: "pipe" }); + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/native-linux-uinput/src/helper/opencontroller-uinput-bridge.c b/packages/native-linux-uinput/src/helper/opencontroller-uinput-bridge.c index 194aea1..3a4ca18 100644 --- a/packages/native-linux-uinput/src/helper/opencontroller-uinput-bridge.c +++ b/packages/native-linux-uinput/src/helper/opencontroller-uinput-bridge.c @@ -10,6 +10,7 @@ #include #include #include +#include "uinput-open.h" #define OC_XINPUT_REPORT_BYTES 12 #define OC_HID_REPORT_BYTES 13 @@ -1015,6 +1016,7 @@ int main(int argc, char **argv) { size_t line_length = 0; int stdin_open = 1; int fd; + unsigned int open_attempt_count = 0; int created = 0; struct oc_rumble_effect effects[OC_MAX_FF_EFFECTS]; char feedback_controller_id[OC_CONTROLLER_ID_MAX]; @@ -1071,10 +1073,14 @@ int main(int argc, char **argv) { set_feedback_controller_id(feedback_controller_id, sizeof(feedback_controller_id), controller_id); - fd = open(device_path, O_RDWR | O_NONBLOCK); + fd = oc_uinput_open_device(device_path, &open_attempt_count); if (fd < 0) { - fprintf(stderr, "opencontroller-uinput: failed to open %s: %s\n", - device_path, strerror(errno)); + fprintf(stderr, + "opencontroller-uinput: failed to open %s after %u attempt%s: %s; " + "check device and permission setup with " + "opencontroller-linux-uinput-doctor --check\n", + device_path, open_attempt_count, + open_attempt_count == 1 ? "" : "s", strerror(errno)); return 1; } diff --git a/packages/native-linux-uinput/src/helper/uinput-open-test.c b/packages/native-linux-uinput/src/helper/uinput-open-test.c new file mode 100644 index 0000000..20359e4 --- /dev/null +++ b/packages/native-linux-uinput/src/helper/uinput-open-test.c @@ -0,0 +1,101 @@ +#include +#include +#include +#include +#include + +#include "uinput-open.h" + +struct open_result { + int fd; + int error; +}; + +static struct open_result results[OC_UINPUT_OPEN_MAX_ATTEMPTS]; +static unsigned int result_count; +static unsigned int open_count; +static unsigned int wait_count; +static unsigned int waits[OC_UINPUT_OPEN_MAX_ATTEMPTS]; + +static int scripted_open(const char *path, int flags) { + struct open_result result; + + assert(strcmp(path, "/test/uinput") == 0); + assert(flags == (O_RDWR | O_NONBLOCK)); + assert(open_count < result_count); + result = results[open_count++]; + if (result.fd < 0) { + errno = result.error; + } + return result.fd; +} + +static void scripted_wait(unsigned int milliseconds) { + assert(wait_count < OC_UINPUT_OPEN_MAX_ATTEMPTS); + waits[wait_count++] = milliseconds; + errno = EINTR; +} + +static void reset_script(const struct open_result *script, unsigned int count) { + memcpy(results, script, count * sizeof(script[0])); + result_count = count; + open_count = 0; + wait_count = 0; +} + +static void test_success_after_transient_failures(void) { + const struct open_result script[] = { + {-1, ENOENT}, {-1, EACCES}, {42, 0}, + }; + unsigned int attempts = 0; + + reset_script(script, sizeof(script) / sizeof(script[0])); + assert(oc_uinput_open_with_retry("/test/uinput", scripted_open, + scripted_wait, &attempts) == 42); + assert(attempts == 3); + assert(open_count == 3); + assert(wait_count == 2); + assert(waits[0] == 10); + assert(waits[1] == 20); +} + +static void test_persistent_failure_stops_at_bound(void) { + const struct open_result script[] = { + {-1, EACCES}, {-1, EACCES}, {-1, EACCES}, {-1, EACCES}, {-1, EACCES}, + }; + unsigned int attempts = 0; + + reset_script(script, sizeof(script) / sizeof(script[0])); + errno = 0; + assert(oc_uinput_open_with_retry("/test/uinput", scripted_open, + scripted_wait, &attempts) == -1); + assert(errno == EACCES); + assert(attempts == OC_UINPUT_OPEN_MAX_ATTEMPTS); + assert(open_count == OC_UINPUT_OPEN_MAX_ATTEMPTS); + assert(wait_count == OC_UINPUT_OPEN_MAX_ATTEMPTS - 1); + assert(waits[0] == 10); + assert(waits[1] == 20); + assert(waits[2] == 40); + assert(waits[3] == 40); +} + +static void test_other_errors_are_not_retried(void) { + const struct open_result script[] = {{-1, EPERM}}; + unsigned int attempts = 0; + + reset_script(script, sizeof(script) / sizeof(script[0])); + errno = 0; + assert(oc_uinput_open_with_retry("/test/uinput", scripted_open, + scripted_wait, &attempts) == -1); + assert(errno == EPERM); + assert(attempts == 1); + assert(open_count == 1); + assert(wait_count == 0); +} + +int main(void) { + test_success_after_transient_failures(); + test_persistent_failure_stops_at_bound(); + test_other_errors_are_not_retried(); + return 0; +} diff --git a/packages/native-linux-uinput/src/helper/uinput-open.h b/packages/native-linux-uinput/src/helper/uinput-open.h new file mode 100644 index 0000000..e8a5ce4 --- /dev/null +++ b/packages/native-linux-uinput/src/helper/uinput-open.h @@ -0,0 +1,79 @@ +#ifndef OPENCONTROLLER_UINPUT_OPEN_H +#define OPENCONTROLLER_UINPUT_OPEN_H + +#include +#include +#include + +#define OC_UINPUT_OPEN_MAX_ATTEMPTS 5 +#define OC_UINPUT_OPEN_INITIAL_BACKOFF_MS 10 +#define OC_UINPUT_OPEN_MAX_BACKOFF_MS 40 + +typedef int (*oc_uinput_open_fn)(const char *path, int flags); +typedef void (*oc_uinput_wait_fn)(unsigned int milliseconds); + +static unsigned int oc_uinput_open_backoff_ms(unsigned int failed_attempt) { + unsigned int delay = OC_UINPUT_OPEN_INITIAL_BACKOFF_MS; + + while (failed_attempt > 1 && delay < OC_UINPUT_OPEN_MAX_BACKOFF_MS) { + delay *= 2; + failed_attempt--; + } + + return delay > OC_UINPUT_OPEN_MAX_BACKOFF_MS + ? OC_UINPUT_OPEN_MAX_BACKOFF_MS + : delay; +} + +static int oc_uinput_open_with_retry(const char *path, oc_uinput_open_fn open_fn, + oc_uinput_wait_fn wait_fn, + unsigned int *attempt_count) { + unsigned int attempt; + + for (attempt = 1; attempt <= OC_UINPUT_OPEN_MAX_ATTEMPTS; attempt++) { + int fd = open_fn(path, O_RDWR | O_NONBLOCK); + int open_error; + + if (attempt_count != NULL) { + *attempt_count = attempt; + } + + if (fd >= 0) { + return fd; + } + + open_error = errno; + if ((open_error != ENOENT && open_error != EACCES) || + attempt == OC_UINPUT_OPEN_MAX_ATTEMPTS) { + errno = open_error; + return -1; + } + + wait_fn(oc_uinput_open_backoff_ms(attempt)); + } + + errno = EIO; + return -1; +} + +static void oc_uinput_wait_for_backoff(unsigned int milliseconds) { + struct timespec delay; + struct timespec remaining; + + delay.tv_sec = (time_t)(milliseconds / 1000); + delay.tv_nsec = (long)(milliseconds % 1000) * 1000000L; + while (nanosleep(&delay, &remaining) < 0 && errno == EINTR) { + delay = remaining; + } +} + +static int oc_system_uinput_open(const char *path, int flags) { + return open(path, flags); +} + +static int oc_uinput_open_device(const char *path, unsigned int *attempt_count) { + return oc_uinput_open_with_retry(path, oc_system_uinput_open, + oc_uinput_wait_for_backoff, attempt_count); +} + +#endif diff --git a/scripts/check-npm-packages.mjs b/scripts/check-npm-packages.mjs index c52421e..d419a94 100644 --- a/scripts/check-npm-packages.mjs +++ b/scripts/check-npm-packages.mjs @@ -21,7 +21,10 @@ const workspaces = [ const sourceAllowlist = new Map([ [ "packages/native-linux-uinput", - new Set(["src/helper/opencontroller-uinput-bridge.c"]), + new Set([ + "src/helper/opencontroller-uinput-bridge.c", + "src/helper/uinput-open.h", + ]), ], ]);