diff --git a/compat/libusb-1.0/libusb.h b/compat/libusb-1.0/libusb.h index 7777df40..3f4267d5 100644 --- a/compat/libusb-1.0/libusb.h +++ b/compat/libusb-1.0/libusb.h @@ -20,6 +20,9 @@ static inline libusb_device_handle *libusb_open_device_with_vid_pid( static inline int libusb_detach_kernel_driver(libusb_device_handle *h, int i) { (void)h; (void)i; return -1; } +static inline int libusb_attach_kernel_driver(libusb_device_handle *h, int i) { + (void)h; (void)i; return -1; +} static inline int libusb_claim_interface(libusb_device_handle *h, int i) { (void)h; (void)i; return -1; } diff --git a/devices/flydigi/vader5.toml b/devices/flydigi/vader5.toml index 81d2c489..7c84b7c2 100644 --- a/devices/flydigi/vader5.toml +++ b/devices/flydigi/vader5.toml @@ -2,13 +2,28 @@ name = "Flydigi Vader 5 Pro" vid = 0x37d7 pid = 0x2401 -block_kernel_drivers = ["xpad"] +block_kernel_drivers = ["xpad", "hid_generic", "usbhid"] -# Only IF1 is used. IF0 is vendor-class (bInterfaceClass=255) with no -# hidraw node; the reference driver also uses IF1 exclusively. +# The pad exposes three HID interfaces (IF1 vendor 0xffa0, IF2 Mouse, IF3 +# vendor 0xffee), each producing a hidraw node via hid-generic. padctl claims +# all three so the kernel exposes no hidraw/evdev node for the physical pad; +# Steam reads hidraw directly, so any kernel-owned node would still surface +# the pad as a generic Xbox controller. IF1 is read for input + rumble + init; +# IF2/IF3 are claimed suppress-only — never read or written, only evicted from +# hid-generic so their hidraw nodes vanish. [[device.interface]] id = 1 -class = "hid" # hidraw; EP2 IN 32B extended input, EP6 OUT 32B config commands +class = "vendor" # EP2 IN 32B extended input, EP6 OUT 32B config commands +ep_in = 0x82 +ep_out = 0x06 + +[[device.interface]] +id = 2 +class = "suppress" # Mouse HID + +[[device.interface]] +id = 3 +class = "suppress" # vendor 0xffee HID [device.init] interface = 1 diff --git a/src/config/device.zig b/src/config/device.zig index c5d4471c..32380e23 100644 --- a/src/config/device.zig +++ b/src/config/device.zig @@ -244,7 +244,83 @@ fn fieldTypeSize(type_str: []const u8) ?i64 { return null; } +pub fn isSuppressClass(class: []const u8) bool { + return std.mem.eql(u8, class, "suppress"); +} + +fn isSuppressInterface(cfg: *const DeviceConfig, iface_id: i64) bool { + for (cfg.device.interface) |iface| { + if (iface.id == iface_id) return isSuppressClass(iface.class); + } + return false; +} + +/// Number of interfaces opened into the devices[] array (everything except +/// suppress-class interfaces). Suppress interfaces are claimed separately and +/// consume no DeviceIO slot. +pub fn openedInterfaceCount(cfg: *const DeviceConfig) usize { + var n: usize = 0; + for (cfg.device.interface) |iface| { + if (!isSuppressClass(iface.class)) n += 1; + } + return n; +} + +/// Map a USB interface id to its index in the devices[] array, counting only +/// non-suppress interfaces. Returns null when the id is unknown or suppressed. +pub fn deviceIndexForInterface(cfg: *const DeviceConfig, iface_id: i64) ?usize { + var idx: usize = 0; + for (cfg.device.interface) |iface| { + if (isSuppressClass(iface.class)) continue; + if (iface.id == iface_id) return idx; + idx += 1; + } + return null; +} + +/// Inverse of deviceIndexForInterface: map a devices[] index back to its +/// InterfaceConfig, skipping suppress interfaces. Returns null when out of range. +pub fn interfaceForDeviceIndex(cfg: *const DeviceConfig, dev_idx: usize) ?*const InterfaceConfig { + var idx: usize = 0; + for (cfg.device.interface) |*iface| { + if (isSuppressClass(iface.class)) continue; + if (idx == dev_idx) return iface; + idx += 1; + } + return null; +} + pub fn validate(cfg: *const DeviceConfig) !void { + for (cfg.device.interface) |iface| { + const is_hid = std.mem.eql(u8, iface.class, "hid"); + const is_vendor = std.mem.eql(u8, iface.class, "vendor"); + const is_suppress = std.mem.eql(u8, iface.class, "suppress"); + if (!is_hid and !is_vendor and !is_suppress) return error.InvalidConfig; + if (is_suppress and (iface.ep_in != null or iface.ep_out != null)) + return error.InvalidConfig; + } + + // An all-suppress config opens no read fd, so it can never be observed + // for liveness; require at least one readable (hid/vendor) interface. + if (openedInterfaceCount(cfg) == 0) return error.InvalidConfig; + + // A suppress interface is claimed only to evict the kernel driver; it is + // never read or written, so no report/command/init may reference it. + for (cfg.report) |report| { + if (isSuppressInterface(cfg, report.interface)) return error.InvalidConfig; + } + if (cfg.commands) |cmds| { + var it = cmds.map.iterator(); + while (it.next()) |entry| { + if (isSuppressInterface(cfg, entry.value_ptr.interface)) return error.InvalidConfig; + } + } + if (cfg.device.init) |init_cfg| { + if (init_cfg.interface) |iface_id| { + if (isSuppressInterface(cfg, iface_id)) return error.InvalidConfig; + } + } + for (cfg.report) |report| { if (report.fields) |fields| { var seen_buf: [64][]const u8 = undefined; @@ -529,6 +605,194 @@ test "device: load flydigi/vader5.toml succeeds" { try std.testing.expectEqualStrings("extended", cfg.report[0].name); } +test "device: vader5 IF1 is claimed via libusb (vendor transport)" { + const allocator = std.testing.allocator; + const result = try parseFile(allocator, "devices/flydigi/vader5.toml"); + defer result.deinit(); + + const cfg = result.value; + // IF1 read transport + IF2/IF3 suppress-only claims. + try std.testing.expectEqual(@as(usize, 3), cfg.device.interface.len); + try std.testing.expectEqual(@as(usize, 1), openedInterfaceCount(&cfg)); + const if1 = cfg.device.interface[0]; + try std.testing.expectEqual(@as(i64, 1), if1.id); + try std.testing.expectEqualStrings("vendor", if1.class); + try std.testing.expectEqual(@as(i64, 0x82), if1.ep_in orelse return error.MissingEpIn); + try std.testing.expectEqual(@as(i64, 0x06), if1.ep_out orelse return error.MissingEpOut); + + try std.testing.expectEqualStrings("suppress", cfg.device.interface[1].class); + try std.testing.expectEqual(@as(i64, 2), cfg.device.interface[1].id); + try std.testing.expect(cfg.device.interface[1].ep_in == null); + try std.testing.expectEqualStrings("suppress", cfg.device.interface[2].class); + try std.testing.expectEqual(@as(i64, 3), cfg.device.interface[2].id); + + const init_cfg = cfg.device.init orelse return error.MissingInit; + try std.testing.expectEqual(@as(i64, 1), init_cfg.interface orelse return error.MissingInterface); + try std.testing.expect(init_cfg.commands != null); + try std.testing.expect(init_cfg.enable != null); +} + +fn suppressIndexToml(comptime suppress_first: bool) []const u8 { + const report_block = + \\[[device.interface]] + \\id = 5 + \\class = "hid" + \\ + ; + const suppress_block = + \\[[device.interface]] + \\id = 9 + \\class = "suppress" + \\ + ; + const head = + \\[device] + \\name = "Mixed" + \\vid = 0x1234 + \\pid = 0x5678 + \\ + ; + const tail = + \\ + \\[[report]] + \\name = "main" + \\interface = 5 + \\size = 16 + \\ + \\[report.match] + \\offset = 0 + \\expect = [0x00] + \\ + \\[report.fields] + \\left_x = { offset = 6, type = "i16le" } + \\ + ; + return if (suppress_first) + head ++ suppress_block ++ report_block ++ tail + else + head ++ report_block ++ suppress_block ++ tail; +} + +test "device: suppress interface excluded from devices[] index regardless of order" { + const allocator = std.testing.allocator; + + inline for (.{ true, false }) |suppress_first| { + const result = try parseString(allocator, suppressIndexToml(suppress_first)); + defer result.deinit(); + const cfg = result.value; + + try validate(&cfg); + try std.testing.expectEqual(@as(usize, 2), cfg.device.interface.len); + // Only the hid interface gets a devices[] slot. + try std.testing.expectEqual(@as(usize, 1), openedInterfaceCount(&cfg)); + // The report interface (id 5) always resolves to devices[0] whether the + // suppress interface (id 9) precedes or follows it. + try std.testing.expectEqual(@as(?usize, 0), deviceIndexForInterface(&cfg, 5)); + try std.testing.expectEqual(@as(?usize, null), deviceIndexForInterface(&cfg, 9)); + // Inverse mapping yields the report interface, never the suppress one. + const iface0 = interfaceForDeviceIndex(&cfg, 0) orelse return error.MissingInterface; + try std.testing.expectEqual(@as(i64, 5), iface0.id); + try std.testing.expectEqual(@as(?*const InterfaceConfig, null), interfaceForDeviceIndex(&cfg, 1)); + } +} + +test "device: validate rejects suppress interface with endpoints" { + const allocator = std.testing.allocator; + const bad = + \\[device] + \\name = "Bad" + \\vid = 0x1234 + \\pid = 0x5678 + \\ + \\[[device.interface]] + \\id = 1 + \\class = "suppress" + \\ep_in = 0x81 + \\ + \\[[report]] + \\name = "main" + \\interface = 1 + \\size = 8 + \\ + \\[report.match] + \\offset = 0 + \\expect = [0x00] + ; + try std.testing.expectError(error.InvalidConfig, parseString(allocator, bad)); +} + +test "device: validate rejects report referencing a suppress interface" { + const allocator = std.testing.allocator; + const bad = + \\[device] + \\name = "Bad" + \\vid = 0x1234 + \\pid = 0x5678 + \\ + \\[[device.interface]] + \\id = 1 + \\class = "suppress" + \\ + \\[[report]] + \\name = "main" + \\interface = 1 + \\size = 8 + \\ + \\[report.match] + \\offset = 0 + \\expect = [0x00] + ; + try std.testing.expectError(error.InvalidConfig, parseString(allocator, bad)); +} + +test "device: validate rejects report->suppress reference even with a readable interface" { + const allocator = std.testing.allocator; + // A readable hid interface (id 5) keeps openedInterfaceCount >= 1 so the + // all-suppress guard does NOT fire; the report targets the suppress + // interface (id 9), so only the report->suppress check can reject it. + const bad = + \\[device] + \\name = "Mixed" + \\vid = 0x1234 + \\pid = 0x5678 + \\ + \\[[device.interface]] + \\id = 5 + \\class = "hid" + \\ + \\[[device.interface]] + \\id = 9 + \\class = "suppress" + \\ + \\[[report]] + \\name = "main" + \\interface = 9 + \\size = 8 + \\ + \\[report.match] + \\offset = 0 + \\expect = [0x00] + ; + try std.testing.expectError(error.InvalidConfig, parseString(allocator, bad)); +} + +test "device: validate rejects an all-suppress config with no readable interface" { + const ifaces = [_]InterfaceConfig{ + .{ .id = 1, .class = "suppress" }, + .{ .id = 2, .class = "suppress" }, + }; + const cfg = DeviceConfig{ + .device = .{ + .name = "AllSuppress", + .vid = 0x1234, + .pid = 0x5678, + .interface = &ifaces, + }, + .report = &.{}, + }; + try std.testing.expectError(error.InvalidConfig, validate(&cfg)); +} + test "device: force_feedback.auto_stop defaults to true when unspecified" { const allocator = std.testing.allocator; const result = try parseString(allocator, test_toml); @@ -614,7 +878,7 @@ test "device: offset out of bounds returns error" { try std.testing.expectError(error.OffsetOutOfBounds, parseString(allocator, bad)); } -test "device: duplicate field name returns error" { +test "device: validate rejects a config with no interface at all" { const cfg = DeviceConfig{ .device = .{ .name = "test", @@ -624,7 +888,7 @@ test "device: duplicate field name returns error" { }, .report = &.{}, }; - try validate(&cfg); + try std.testing.expectError(error.InvalidConfig, validate(&cfg)); } test "device: invalid transform returns error" { diff --git a/src/device_instance.zig b/src/device_instance.zig index 9cc24daa..dd8bfd0f 100644 --- a/src/device_instance.zig +++ b/src/device_instance.zig @@ -5,6 +5,7 @@ const posix = std.posix; const DeviceIO = @import("io/device_io.zig").DeviceIO; const HidrawDevice = @import("io/hidraw.zig").HidrawDevice; const UsbrawDevice = @import("io/usbraw.zig").UsbrawDevice; +const UsbrawSuppress = @import("io/usbraw.zig").UsbrawSuppress; const uinput = @import("io/uinput.zig"); const UinputDevice = uinput.UinputDevice; const AuxDevice = uinput.AuxDevice; @@ -181,6 +182,9 @@ pub fn openUhidDeviceForTest( pub const DeviceInstance = struct { allocator: std.mem.Allocator, devices: []DeviceIO, + /// Interfaces claimed via libusb solely to evict the kernel driver so the + /// physical device exposes no hidraw node for them. Never read or written. + suppress_devs: []*UsbrawSuppress = &.{}, loop: EventLoop, interp: Interpreter, mapper: ?Mapper, @@ -239,27 +243,49 @@ pub const DeviceInstance = struct { const pid: u16 = @intCast(cfg.device.pid); const override_active = opts.test_devices_override != null; - const devices = opts.test_devices_override orelse try allocator.alloc(DeviceIO, cfg.device.interface.len); + const devices = opts.test_devices_override orelse try allocator.alloc(DeviceIO, device_cfg.openedInterfaceCount(cfg)); errdefer if (!override_active) allocator.free(devices); var opened: usize = 0; errdefer for (devices[0..opened]) |dev| dev.close(); + // Suppress interfaces are claimed only to evict the kernel driver; they + // are not read or written and get no DeviceIO slot. + const suppress_count = cfg.device.interface.len - device_cfg.openedInterfaceCount(cfg); + const suppress_devs: []*UsbrawSuppress = if (!override_active and suppress_count > 0) + try allocator.alloc(*UsbrawSuppress, suppress_count) + else + &.{}; + errdefer if (!override_active and suppress_count > 0) allocator.free(suppress_devs); + + var suppressed: usize = 0; + errdefer for (suppress_devs[0..suppressed]) |sd| sd.close(); + if (!override_active) { - for (cfg.device.interface, 0..) |iface, i| { - devices[i] = try openDeviceWithRetry(allocator, iface, vid, pid); + // Pass 1: open hid/vendor interfaces into devices[] positionally. + for (cfg.device.interface) |iface| { + if (device_cfg.isSuppressClass(iface.class)) continue; + devices[opened] = try openDeviceWithRetry(allocator, iface, vid, pid); opened += 1; } + // Pass 2: claim suppress interfaces to remove their hidraw nodes. + for (cfg.device.interface) |iface| { + if (!device_cfg.isSuppressClass(iface.class)) continue; + suppress_devs[suppressed] = try UsbrawSuppress.openSuppress(allocator, vid, pid, @intCast(iface.id)); + suppressed += 1; + } } if (cfg.device.init) |init_cfg| { - for (cfg.device.interface, devices) |iface, dev| { + for (cfg.device.interface) |iface| { + if (device_cfg.isSuppressClass(iface.class)) continue; + const dev_idx = device_cfg.deviceIndexForInterface(cfg, iface.id) orelse continue; const match = if (init_cfg.interface) |init_iface| iface.id == init_iface else std.mem.eql(u8, iface.class, "vendor"); if (!match) continue; - init_seq.runInitSequence(allocator, dev, init_cfg) catch |err| { + init_seq.runInitSequence(allocator, devices[dev_idx], init_cfg) catch |err| { std.log.debug("init on interface {d}: {}", .{ iface.id, err }); return err; }; @@ -475,6 +501,7 @@ pub const DeviceInstance = struct { return .{ .allocator = allocator, .devices = devices, + .suppress_devs = suppress_devs, .loop = loop, .interp = interp, .mapper = mapper, @@ -540,6 +567,8 @@ pub const DeviceInstance = struct { if (self.aux_dev) |*a| a.close(); if (self.touchpad_dev) |*tp| tp.close(); if (self.generic_uinput) |*gu| gu.close(); + for (self.suppress_devs) |sd| sd.close(); + if (self.suppress_devs.len > 0) self.allocator.free(self.suppress_devs); for (self.devices) |dev| dev.close(); self.allocator.free(self.devices); self.loop.deinit(); @@ -711,13 +740,15 @@ pub const DeviceInstance = struct { /// current devices[] fds and device_cfg. pub fn rerunInitSequence(self: *DeviceInstance) !void { if (self.device_cfg.device.init) |init_cfg| { - for (self.device_cfg.device.interface, self.devices) |iface, dev| { + for (self.device_cfg.device.interface) |iface| { + if (device_cfg.isSuppressClass(iface.class)) continue; + const dev_idx = device_cfg.deviceIndexForInterface(self.device_cfg, iface.id) orelse continue; const match = if (init_cfg.interface) |init_iface| iface.id == init_iface else std.mem.eql(u8, iface.class, "vendor"); if (!match) continue; - init_seq.runInitSequence(self.allocator, dev, init_cfg) catch |err| { + init_seq.runInitSequence(self.allocator, self.devices[dev_idx], init_cfg) catch |err| { std.log.debug("re-init on interface {d}: {}", .{ iface.id, err }); return err; }; @@ -988,6 +1019,114 @@ test "DeviceInstance.init propagates feature_report init errors" { try testing.expectError(DeviceIO.WriteError.Io, result); } +const suppress_first_init_toml = + \\[device] + \\name = "SuppressFirst" + \\vid = 1 + \\pid = 2 + \\[[device.interface]] + \\id = 0 + \\class = "suppress" + \\[[device.interface]] + \\id = 1 + \\class = "hid" + \\[[device.interface]] + \\id = 2 + \\class = "hid" + \\[device.init] + \\interface = 1 + \\commands = ["aabb"] + \\[[report]] + \\name = "r1" + \\interface = 1 + \\size = 1 + \\[report.match] + \\offset = 0 + \\expect = [0x01] + \\[[report]] + \\name = "r2" + \\interface = 2 + \\size = 1 + \\[report.match] + \\offset = 0 + \\expect = [0x02] +; + +const report_then_suppress_init_toml = + \\[device] + \\name = "ReportThenSuppress" + \\vid = 1 + \\pid = 2 + \\[[device.interface]] + \\id = 0 + \\class = "hid" + \\[[device.interface]] + \\id = 1 + \\class = "suppress" + \\[device.init] + \\interface = 0 + \\commands = ["ccdd"] + \\[[report]] + \\name = "r" + \\interface = 0 + \\size = 1 + \\[report.match] + \\offset = 0 + \\expect = [0x01] +; + +// Regression guard for the suppress-interface index alignment (issue #355). +// The init-handshake loop must route the init command to the devices[] slot +// computed by deviceIndexForInterface, NOT to a positional interface[i] +// counter. With a suppress interface preceding the report interfaces, a +// positional counter would target the wrong mock (or overflow). +test "DeviceInstance.init: suppress preceding report routes init via helper, not positional" { + const allocator = testing.allocator; + + { + const parsed = try device_mod.parseString(allocator, suppress_first_init_toml); + defer parsed.deinit(); + + var mock0 = try MockDeviceIO.init(allocator, &.{}); + defer mock0.deinit(); + var mock1 = try MockDeviceIO.init(allocator, &.{}); + defer mock1.deinit(); + + const devices = try allocator.alloc(DeviceIO, 2); + devices[0] = mock0.deviceIO(); + devices[1] = mock1.deviceIO(); + + var uniq_counter: u16 = 1; + var inst = try DeviceInstance.init(allocator, &parsed.value, null, null, &uniq_counter, .{ + .test_devices_override = devices, + }); + defer inst.deinit(); + + // init.interface = 1 maps to devices[0] (suppress id=0 consumes no slot). + try testing.expectEqualSlices(u8, &[_]u8{ 0xaa, 0xbb }, mock0.write_log.items); + try testing.expectEqual(@as(usize, 0), mock1.write_log.items.len); + } + + { + const parsed = try device_mod.parseString(allocator, report_then_suppress_init_toml); + defer parsed.deinit(); + + var mock = try MockDeviceIO.init(allocator, &.{}); + defer mock.deinit(); + + const devices = try allocator.alloc(DeviceIO, 1); + devices[0] = mock.deviceIO(); + + var uniq_counter: u16 = 1; + var inst = try DeviceInstance.init(allocator, &parsed.value, null, null, &uniq_counter, .{ + .test_devices_override = devices, + }); + defer inst.deinit(); + + try testing.expectEqualSlices(u8, &[_]u8{ 0xcc, 0xdd }, mock.write_log.items); + } +} + test "DeviceInstance: rerunInitSequence propagates init write errors" { const allocator = testing.allocator; diff --git a/src/event_loop.zig b/src/event_loop.zig index 83ea3e05..a8ebd428 100644 --- a/src/event_loop.zig +++ b/src/event_loop.zig @@ -14,7 +14,8 @@ const GenericOutputDevice = @import("io/uinput.zig").GenericOutputDevice; const state = @import("core/state.zig"); const GamepadStateDelta = state.GamepadStateDelta; const mapper_mod = @import("core/mapper.zig"); -const DeviceConfig = @import("config/device.zig").DeviceConfig; +const device_cfg = @import("config/device.zig"); +const DeviceConfig = device_cfg.DeviceConfig; const command = @import("core/command.zig"); const fillTemplate = command.fillTemplate; const applyChecksum = command.applyChecksum; @@ -317,14 +318,11 @@ fn buildAdaptiveTriggerParams(buf: *[12]Param, at: *const AdaptiveTriggerConfig) return buf[0..12]; } -/// Resolve a USB interface ID to the devices array index by matching -/// against the device config's interface list. Returns null when the -/// interface ID is not found. +/// Resolve a USB interface ID to the devices array index, counting only +/// non-suppress interfaces so routing is independent of TOML ordering. +/// Returns null when the interface ID is unknown or suppress-class. fn resolveIfaceIdx(dcfg: *const DeviceConfig, iface_id: i64) ?usize { - for (dcfg.device.interface, 0..) |iface, i| { - if (iface.id == iface_id) return i; - } - return null; + return device_cfg.deviceIndexForInterface(dcfg, iface_id); } pub fn applyAdaptiveTrigger( @@ -736,7 +734,7 @@ pub const EventLoop = struct { if (n == 0) break; const interface_id: u8 = if (ctx.device_config) |dcfg| - @intCast(dcfg.device.interface[i].id) + @intCast((device_cfg.interfaceForDeviceIndex(dcfg, i) orelse continue).id) else @intCast(i); diff --git a/src/io/usbraw.zig b/src/io/usbraw.zig index c5f9f5ed..f6fe64bc 100644 --- a/src/io/usbraw.zig +++ b/src/io/usbraw.zig @@ -101,11 +101,13 @@ pub const UsbrawDevice = struct { const rc = c.libusb_claim_interface(handle, interface_id); if (rc == c.LIBUSB_ERROR_BUSY) { + _ = c.libusb_attach_kernel_driver(handle, interface_id); c.libusb_close(handle); c.libusb_exit(ctx); return error.Busy; } if (rc != 0) { + _ = c.libusb_attach_kernel_driver(handle, interface_id); c.libusb_close(handle); c.libusb_exit(ctx); return error.ClaimFailed; @@ -140,6 +142,16 @@ pub const UsbrawDevice = struct { return self; } + fn closeWriteEnd(self: *UsbrawDevice) void { + const fd = @atomicRmw(std.posix.fd_t, &self.pipe_w, .Xchg, -1, .acq_rel); + if (fd >= 0) std.posix.close(fd); + } + + fn signalDisconnect(self: *UsbrawDevice) void { + self.disconnected.store(true, .release); + self.closeWriteEnd(); + } + fn readLoop(self: *UsbrawDevice) void { var buf: [RingBuffer.SLOT_SIZE]u8 = undefined; var transferred: c_int = 0; @@ -155,8 +167,7 @@ pub const UsbrawDevice = struct { ); if (rc == c.LIBUSB_ERROR_NO_DEVICE) { - self.disconnected.store(true, .release); - _ = std.posix.write(self.pipe_w, "\x01") catch {}; + self.signalDisconnect(); break; } @@ -223,9 +234,72 @@ pub const UsbrawDevice = struct { const self: *UsbrawDevice = @ptrCast(@alignCast(ptr)); self.should_stop.store(true, .release); self.thread.join(); - std.posix.close(self.pipe_w); + self.closeWriteEnd(); std.posix.close(self.pipe_r); _ = c.libusb_release_interface(self.handle, self.interface_id); + _ = c.libusb_attach_kernel_driver(self.handle, self.interface_id); + c.libusb_close(self.handle); + c.libusb_exit(self.ctx); + self.allocator.destroy(self); + } +}; + +// Claims an interface purely to evict the kernel driver, so the device +// exposes no hidraw/evdev node for it. No reads, no writes, no poll fd. +pub const UsbrawSuppress = struct { + handle: *c.libusb_device_handle, + ctx: *c.libusb_context, + interface_id: i32, + allocator: std.mem.Allocator, + + pub fn openSuppress( + alloc: std.mem.Allocator, + vid: u16, + pid: u16, + interface_id: u8, + ) !*UsbrawSuppress { + var ctx: ?*c.libusb_context = null; + if (c.libusb_init(&ctx) != 0) return error.LibusbInit; + + const handle = c.libusb_open_device_with_vid_pid(ctx, vid, pid) orelse { + c.libusb_exit(ctx); + return error.NotFound; + }; + + _ = c.libusb_detach_kernel_driver(handle, interface_id); + + const rc = c.libusb_claim_interface(handle, interface_id); + if (rc == c.LIBUSB_ERROR_BUSY) { + _ = c.libusb_attach_kernel_driver(handle, interface_id); + c.libusb_close(handle); + c.libusb_exit(ctx); + return error.Busy; + } + if (rc != 0) { + _ = c.libusb_attach_kernel_driver(handle, interface_id); + c.libusb_close(handle); + c.libusb_exit(ctx); + return error.ClaimFailed; + } + + const self = alloc.create(UsbrawSuppress) catch |err| { + _ = c.libusb_release_interface(handle, interface_id); + c.libusb_close(handle); + c.libusb_exit(ctx); + return err; + }; + self.* = .{ + .handle = handle, + .ctx = ctx.?, + .interface_id = @intCast(interface_id), + .allocator = alloc, + }; + return self; + } + + pub fn close(self: *UsbrawSuppress) void { + _ = c.libusb_release_interface(self.handle, self.interface_id); + _ = c.libusb_attach_kernel_driver(self.handle, self.interface_id); c.libusb_close(self.handle); c.libusb_exit(self.ctx); self.allocator.destroy(self); @@ -282,6 +356,26 @@ test "usbraw: RingBuffer wraps around correctly" { try std.testing.expectEqual(@as(usize, 0), rb.count); } +test "usbraw: signalDisconnect closes write end and raises POLLHUP" { + const pipe_fds = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); + var dev: UsbrawDevice = undefined; + dev.pipe_r = pipe_fds[0]; + dev.pipe_w = pipe_fds[1]; + dev.disconnected = std.atomic.Value(bool).init(false); + + dev.signalDisconnect(); + + try std.testing.expectEqual(@as(std.posix.fd_t, -1), dev.pipe_w); + try std.testing.expect(dev.disconnected.load(.acquire)); + + var fds = [_]std.posix.pollfd{.{ .fd = dev.pipe_r, .events = std.posix.POLL.IN, .revents = 0 }}; + const ready = try std.posix.poll(&fds, 1000); + try std.testing.expect(ready == 1); + try std.testing.expect(fds[0].revents & std.posix.POLL.HUP != 0); + + std.posix.close(dev.pipe_r); +} + test "usbraw: RingBuffer concurrent push/pop" { var ring = RingBuffer{}; diff --git a/src/supervisor.zig b/src/supervisor.zig index 7db1f96a..d15dbbd2 100644 --- a/src/supervisor.zig +++ b/src/supervisor.zig @@ -172,8 +172,8 @@ const HotplugPending = struct { retries: u8, }; -// 7 fixed (stop, hup, netlink, inotify, debounce, hotplug_retry, grace) + 1 listen + 4 clients. -pub const SUPERVISOR_MAX_FDS: usize = 7 + 1 + 4; +// 8 fixed (stop, hup, netlink, inotify, debounce, hotplug_retry, grace, liveness) + 1 listen + 4 clients. +pub const SUPERVISOR_MAX_FDS: usize = 8 + 1 + 4; pub const Supervisor = struct { allocator: std.mem.Allocator, @@ -204,6 +204,12 @@ pub const Supervisor = struct { /// `detach()`; drained by `drainGraceTimer()`. -1 = unavailable /// (e.g. `initForTest`); callers must call `gcExpiredGrace()` directly. grace_timer_fd: posix.fd_t = -1, + /// Recurring timerfd (1s) that sweeps managed libusb-backed instances for a + /// real physical unplug. These instances never receive a hidraw REMOVE that + /// means "unplug" (their hidraw node was deleted by padctl's own claim), so + /// liveness is probed via the UsbrawDevice pipe fd instead. -1 = unavailable + /// (e.g. `initForTest`). + liveness_timer_fd: posix.fd_t = -1, /// Test-only clock override (ns). When non-null, `nowNs()` returns /// this value instead of reading CLOCK_MONOTONIC. Production paths /// leave this null. @@ -266,6 +272,20 @@ pub const Supervisor = struct { }; errdefer if (grace_fd >= 0) posix.close(grace_fd); + // Recurring 1s liveness sweep for libusb-backed instances. + const liveness_fd = posix.timerfd_create(.MONOTONIC, .{ .CLOEXEC = true, .NONBLOCK = true }) catch blk: { + std.log.warn("liveness sweep timer unavailable", .{}); + break :blk -1; + }; + errdefer if (liveness_fd >= 0) posix.close(liveness_fd); + if (liveness_fd >= 0) { + const spec = linux.itimerspec{ + .it_value = .{ .sec = 1, .nsec = 0 }, + .it_interval = .{ .sec = 1, .nsec = 0 }, + }; + _ = linux.timerfd_settime(liveness_fd, .{}, &spec, null); + } + const inotify_result = initInotify(allocator); var sock_path_buf: [256]u8 = undefined; @@ -295,6 +315,7 @@ pub const Supervisor = struct { .test_switch_mapping_override = null, .test_switch_fail_commit_index = null, .grace_timer_fd = grace_fd, + .liveness_timer_fd = liveness_fd, .trace_lifecycle = trace_on, }; sup.applyUserConfigRuntime(); @@ -361,6 +382,7 @@ pub const Supervisor = struct { if (self.debounce_fd >= 0) posix.close(self.debounce_fd); if (self.hotplug_retry_fd >= 0) posix.close(self.hotplug_retry_fd); if (self.grace_timer_fd >= 0) posix.close(self.grace_timer_fd); + if (self.liveness_timer_fd >= 0) posix.close(self.liveness_timer_fd); self.hotplug_pending.deinit(self.allocator); if (self.config_dir) |dir| self.allocator.free(dir); if (self.managed.items.len > 0) self.stopAll(); @@ -501,6 +523,24 @@ pub const Supervisor = struct { return; } + // Peek first: a libusb-backed instance must keep its devname binding so + // it stays addressable. The hidraw REMOVE that triggered this detach is + // caused by padctl's own libusb claim deleting the node, not a physical + // unplug; real unplug for these instances is detected by the liveness + // sweep over the UsbrawDevice pipe fd. + const peek = self.devname_map.get(devname) orelse { + std.log.debug("detach: {s} not in devname_map", .{devname}); + return; + }; + for (self.managed.items) |*m| { + if (!std.mem.eql(u8, m.phys_key, peek)) continue; + if (instanceHoldsLibusb(m)) { + std.log.debug("detach: {s} holds libusb; ignoring hidraw REMOVE", .{devname}); + return; + } + break; + } + const entry = self.devname_map.fetchRemove(devname) orelse { std.log.debug("detach: {s} not in devname_map", .{devname}); return; @@ -584,6 +624,45 @@ pub const Supervisor = struct { self.armGraceTimer(); } + /// Drain `liveness_timer_fd` and sweep libusb-backed instances. Called by + /// the serve loop on the recurring 1s fire. + fn drainLivenessTimer(self: *Supervisor) void { + if (self.liveness_timer_fd < 0) return; + var tbuf: [8]u8 = undefined; + _ = posix.read(self.liveness_timer_fd, &tbuf) catch {}; + self.sweepLivenessLibusb(); + } + + /// Tear down any managed libusb-backed instance whose backing device fd has + /// hung up (physical unplug). Pure hid-class instances are left to the + /// hidraw REMOVE + grace path and are not touched here. Exposed for tests. + pub fn sweepLivenessLibusb(self: *Supervisor) void { + var i: usize = self.managed.items.len; + while (i > 0) { + i -= 1; + const m = &self.managed.items[i]; + if (!instanceHoldsLibusb(m)) continue; + if (m.suspended) continue; + // No read fd to probe for POLLHUP — unplug detection here is out of scope. + if (m.instance.devices.len == 0) continue; + if (managedInstanceAlive(m)) continue; + if (m.devname) |devname| { + std.log.info("device unplugged: \"{s}\" {s}; tearing down", .{ m.instance.device_cfg.device.name, devname }); + self.detachFull(devname); + } else { + std.log.info("device unplugged: \"{s}\" phys=\"{s}\"; tearing down", .{ + m.instance.device_cfg.device.name, + m.phys_key, + }); + m.instance.stop(); + m.thread.join(); + m.instance.quiesceOutputs(.{ .reset_input_state = true, .reset_mapper_state = true }); + self.teardownManaged(m); + _ = self.managed.swapRemove(i); + } + } + } + /// Arm the grace timerfd to fire at the soonest pending deadline. If /// no entries are pending, the timer is disarmed. A no-op when /// `grace_timer_fd < 0` (e.g. `initForTest`). @@ -655,6 +734,20 @@ pub const Supervisor = struct { } } + /// True when the instance owns the physical device through libusb rather + /// than through a kernel hidraw node: it claims a suppress-only interface + /// or reads a vendor-class interface. Such an instance must not be torn + /// down on a hidraw REMOVE uevent — the REMOVE is a side effect of padctl's + /// own claim deleting the hidraw node, not a physical unplug. + fn instanceHoldsLibusb(m: *const ManagedInstance) bool { + if (m.instance.suppress_devs.len > 0) return true; + for (m.instance.device_cfg.device.interface) |iface| { + if (config_device.isSuppressClass(iface.class)) continue; + if (std.mem.eql(u8, iface.class, "vendor")) return true; + } + return false; + } + /// Probe whether the backing device fds of `m` are still alive. /// Returns false when the primary device fd has been invalidated (EBADF) /// or the other end has hung up (POLLHUP / POLLERR / POLLNVAL), which @@ -1316,7 +1409,7 @@ pub const Supervisor = struct { /// is unavailable (e.g. `initForTest` skips netlink/inotify/grace_timer). /// Stop and hup always occupy slots 0/1; the rest are assigned in the /// fixed order netlink → inotify → debounce → hotplug_retry → grace_timer - /// → listen, packed contiguously starting at slot 2. + /// → liveness_timer → listen, packed contiguously starting at slot 2. const SupervisorPollSet = struct { base_nfds: usize, netlink_slot: ?usize, @@ -1324,6 +1417,7 @@ pub const Supervisor = struct { debounce_slot: ?usize, hotplug_retry_slot: ?usize, grace_timer_slot: ?usize, + liveness_timer_slot: ?usize, listen_slot: ?usize, fn init(self: *const Supervisor, pollfds: *[SUPERVISOR_MAX_FDS]posix.pollfd) SupervisorPollSet { @@ -1360,6 +1454,12 @@ pub const Supervisor = struct { base_nfds += 1; break :blk s; } else null; + const liveness_timer_slot: ?usize = if (self.liveness_timer_fd >= 0) blk: { + pollfds[base_nfds] = .{ .fd = self.liveness_timer_fd, .events = posix.POLL.IN, .revents = 0 }; + const s = base_nfds; + base_nfds += 1; + break :blk s; + } else null; const listen_slot: ?usize = if (self.ctrl_sock) |cs| blk: { pollfds[base_nfds] = cs.pollfd(); const s = base_nfds; @@ -1374,6 +1474,7 @@ pub const Supervisor = struct { .debounce_slot = debounce_slot, .hotplug_retry_slot = hotplug_retry_slot, .grace_timer_slot = grace_timer_slot, + .liveness_timer_slot = liveness_timer_slot, .listen_slot = listen_slot, }; } @@ -1458,6 +1559,13 @@ pub const Supervisor = struct { } } + if (set.liveness_timer_slot) |slot| { + if (pollfds[slot].revents & posix.POLL.IN != 0) { + self.drainLivenessTimer(); + pollfds[slot].revents = 0; + } + } + if (set.listen_slot) |slot| { if (pollfds[slot].revents & posix.POLL.IN != 0) { self.ctrl_sock.?.acceptClient(); @@ -2344,7 +2452,10 @@ pub const Supervisor = struct { // port starts a fresh instance instead of inheriting stale state. if (!phys_match or !id_match) continue; - const new_devices = try self.allocator.alloc(DeviceIO, mcfg.device.interface.len); + // Suppress interfaces stay claimed across suspend/resume (never + // closed by closeDeviceIO), so only non-suppress interfaces are + // reopened into the rebind array. + const new_devices = try self.allocator.alloc(DeviceIO, config_device.openedInterfaceCount(mcfg)); var opened: usize = 0; var new_devices_owned = true; errdefer { @@ -2353,8 +2464,9 @@ pub const Supervisor = struct { self.allocator.free(new_devices); } } - for (mcfg.device.interface, 0..) |iface, idx| { - new_devices[idx] = opener(opener_ctx, self.allocator, iface, vid, pid) catch |err| { + for (mcfg.device.interface) |iface| { + if (config_device.isSuppressClass(iface.class)) continue; + new_devices[opened] = opener(opener_ctx, self.allocator, iface, vid, pid) catch |err| { std.log.warn("rebind: open interface {d} failed: {}", .{ iface.id, err }); for (new_devices[0..opened]) |dev| dev.close(); self.allocator.free(new_devices); @@ -2405,6 +2517,7 @@ const MockDeviceIO = @import("test/mock_device_io.zig").MockDeviceIO; const MockOutput = @import("test/mock_output.zig").MockOutput; const uinput = @import("io/uinput.zig"); const state_mod = @import("core/state.zig"); +const usbraw_mod = @import("io/usbraw.zig"); const minimal_device_toml = \\[device] @@ -2425,6 +2538,31 @@ const minimal_device_toml = \\left_x = { offset = 1, type = "i16le" } ; +// Vendor read interface + suppress claim-only interface (Vader-5 shape). +const libusb_device_toml = + \\[device] + \\name = "Libusb Pad" + \\vid = 1 + \\pid = 2 + \\[[device.interface]] + \\id = 1 + \\class = "vendor" + \\ep_in = 0x82 + \\ep_out = 0x06 + \\[[device.interface]] + \\id = 2 + \\class = "suppress" + \\[[report]] + \\name = "r" + \\interface = 1 + \\size = 3 + \\[report.match] + \\offset = 0 + \\expect = [0x01] + \\[report.fields] + \\left_x = { offset = 1, type = "i16le" } +; + const init_device_toml = \\[device] \\name = "InitDevice" @@ -2574,6 +2712,39 @@ fn makeTestInstance( return inst; } +// Build a DeviceInstance with no read fd (all-suppress shape): devices[] is +// empty so managedInstanceAlive() takes its len==0 shortcut. The returned +// instance is fully deinit-safe (no outputs, no registered fds). +fn makeFdlessInstance( + inst_alloc: std.mem.Allocator, + cfg: *const device_mod.DeviceConfig, +) !*DeviceInstance { + const devices = try inst_alloc.alloc(DeviceIO, 0); + var loop = try EventLoop.initManaged(); + errdefer loop.deinit(); + + const inst = try inst_alloc.create(DeviceInstance); + inst.* = .{ + .allocator = inst_alloc, + .devices = devices, + .loop = loop, + .interp = @import("core/interpreter.zig").Interpreter.init(cfg), + .mapper = null, + .owner = .none, + .primary_output = null, + .imu_output = null, + .aux_dev = null, + .touchpad_dev = null, + .generic_state = null, + .generic_uinput = null, + .device_cfg = cfg, + .pending_mapping = null, + .stopped = true, + .poll_timeout_ms = 100, + }; + return inst; +} + // threadlocal: Zig test runner executes each test in its own OS thread from a pool. // threadlocal gives each test thread an independent slot, preventing cross-test // interference when tests run in parallel. Limitation: tests that call reload() @@ -2827,6 +2998,192 @@ test "supervisor: detach quiesces primary output before suspension" { try testing.expect(std.meta.eql(state_mod.GamepadState{}, inst.mapper.?.state)); } +test "supervisor: instanceHoldsLibusb true for vendor interface, false for pure hid" { + const allocator = testing.allocator; + + const libusb_dev = try device_mod.parseString(allocator, libusb_device_toml); + defer libusb_dev.deinit(); + const hid_dev = try device_mod.parseString(allocator, minimal_device_toml); + defer hid_dev.deinit(); + + var mock_a = try MockDeviceIO.init(allocator, &.{}); + defer mock_a.deinit(); + var mock_b = try MockDeviceIO.init(allocator, &.{}); + defer mock_b.deinit(); + + var sup = try Supervisor.initForTest(allocator); + defer { + sup.stopAll(); + sup.deinit(); + } + + const inst_libusb = try makeTestInstance(allocator, &mock_a, &libusb_dev.value); + const inst_hid = try makeTestInstance(allocator, &mock_b, &hid_dev.value); + try testing.expect(try sup.attachWithInstanceResult("hidraw0", "phys-libusb", inst_libusb, null)); + try testing.expect(try sup.attachWithInstanceResult("hidraw1", "phys-hid", inst_hid, null)); + + try testing.expectEqual(@as(usize, 2), sup.managed.items.len); + for (sup.managed.items) |*m| { + if (std.mem.eql(u8, m.phys_key, "phys-libusb")) { + try testing.expect(Supervisor.instanceHoldsLibusb(m)); + } else { + try testing.expect(!Supervisor.instanceHoldsLibusb(m)); + } + } +} + +test "supervisor: detach on libusb instance does not suspend and keeps devname binding" { + const allocator = testing.allocator; + const parsed_dev = try device_mod.parseString(allocator, libusb_device_toml); + defer parsed_dev.deinit(); + + var mock = try MockDeviceIO.init(allocator, &.{}); + defer mock.deinit(); + + var sup = try Supervisor.initForTest(allocator); + defer { + sup.stopAll(); + sup.deinit(); + } + + const inst = try makeTestInstance(allocator, &mock, &parsed_dev.value); + try testing.expect(try sup.attachWithInstanceResult("hidraw0", "phys0", inst, null)); + + sup.detach("hidraw0"); + + // The hidraw REMOVE that detach() saw is padctl's own claim deleting the + // node — the instance must stay live and addressable. + try testing.expectEqual(@as(usize, 1), sup.managed.items.len); + try testing.expect(!sup.managed.items[0].suspended); + try testing.expect(sup.managed.items[0].grace_deadline_ns == null); + try testing.expect(sup.devname_map.contains("hidraw0")); + try testing.expect(sup.managed.items[0].devname != null); +} + +test "supervisor: detach on pure-hid instance still suspends (no regression)" { + const allocator = testing.allocator; + const parsed_dev = try device_mod.parseString(allocator, minimal_device_toml); + defer parsed_dev.deinit(); + + var mock = try MockDeviceIO.init(allocator, &.{}); + defer mock.deinit(); + + var sup = try Supervisor.initForTest(allocator); + defer { + sup.stopAll(); + sup.deinit(); + } + + const inst = try makeTestInstance(allocator, &mock, &parsed_dev.value); + try testing.expect(try sup.attachWithInstanceResult("hidraw0", "phys0", inst, null)); + + sup.detach("hidraw0"); + + try testing.expectEqual(@as(usize, 1), sup.managed.items.len); + try testing.expect(sup.managed.items[0].suspended); + try testing.expect(!sup.devname_map.contains("hidraw0")); +} + +test "supervisor: liveness sweep tears down libusb instance whose pipe hung up" { + const allocator = testing.allocator; + const parsed_dev = try device_mod.parseString(allocator, libusb_device_toml); + defer parsed_dev.deinit(); + + var mock = try MockDeviceIO.init(allocator, &.{}); + defer mock.deinit(); + + var sup = try Supervisor.initForTest(allocator); + defer { + sup.stopAll(); + sup.deinit(); + } + + const inst = try makeTestInstance(allocator, &mock, &parsed_dev.value); + try testing.expect(try sup.attachWithInstanceResult("hidraw0", "phys0", inst, null)); + sup.detach("hidraw0"); // libusb path: stays live, binding preserved + try testing.expectEqual(@as(usize, 1), sup.managed.items.len); + + // Still alive while the pipe is open. + sup.sweepLivenessLibusb(); + try testing.expectEqual(@as(usize, 1), sup.managed.items.len); + + // Close the write end → pollfd sees POLLHUP → managedInstanceAlive false. + mock.closeWriteEnd(); + sup.sweepLivenessLibusb(); + try testing.expectEqual(@as(usize, 0), sup.managed.items.len); + try testing.expect(!sup.devname_map.contains("hidraw0")); +} + +// A libusb instance spawned without a devname (spawnInstance path, e.g. run() +// or doReload's found==null branch) must still be reaped by the sweep on +// unplug. Pre-fix `m.devname orelse continue` skipped it, leaking the worker +// thread and instance forever. +test "supervisor: liveness sweep tears down devname-null libusb instance" { + const allocator = testing.allocator; + const parsed_dev = try device_mod.parseString(allocator, libusb_device_toml); + defer parsed_dev.deinit(); + + var mock = try MockDeviceIO.init(allocator, &.{}); + defer mock.deinit(); + + var sup = try Supervisor.initForTest(allocator); + defer { + sup.stopAll(); + sup.deinit(); + } + + const inst = try makeTestInstance(allocator, &mock, &parsed_dev.value); + try sup.spawnInstance("phys0", inst, null); + try testing.expectEqual(@as(usize, 1), sup.managed.items.len); + try testing.expect(sup.managed.items[0].devname == null); + + // Still alive while the pipe is open. + sup.sweepLivenessLibusb(); + try testing.expectEqual(@as(usize, 1), sup.managed.items.len); + + // POLLHUP → managedInstanceAlive false. Without the else-branch the sweep + // hits `m.devname orelse continue` and leaves managed.items.len == 1. + mock.closeWriteEnd(); + sup.sweepLivenessLibusb(); + try testing.expectEqual(@as(usize, 0), sup.managed.items.len); +} + +test "supervisor: liveness sweep spares an all-suppress instance with no read fd" { + const allocator = testing.allocator; + const parsed_dev = try device_mod.parseString(allocator, libusb_device_toml); + defer parsed_dev.deinit(); + + var sup = try Supervisor.initForTest(allocator); + defer { + sup.stopAll(); + sup.deinit(); + } + + const inst = try makeFdlessInstance(allocator, &parsed_dev.value); + // attachWithInstanceResult registers a devname in devname_map; without it + // detachFull is unreachable and the sweep could never tear the entry down, + // which would make this test pass even without the guard under test. + try testing.expect(try sup.attachWithInstanceResult("hidraw0", "phys-suppress", inst, null)); + try testing.expectEqual(@as(usize, 1), sup.managed.items.len); + try testing.expect(sup.devname_map.contains("hidraw0")); + + // Mimic a claim-only instance: holds the device via libusb (suppress_devs + // non-empty) but exposes no readable interface (devices[] empty). The + // pointer is never dereferenced — only the slice length is read — so a + // dummy is sufficient. Reset to empty before teardown so deinit does not + // call close() on it. + var dummy_suppress: *usbraw_mod.UsbrawSuppress = undefined; + inst.suppress_devs = (&dummy_suppress)[0..1]; + defer sup.managed.items[0].instance.suppress_devs = &.{}; + + try testing.expect(Supervisor.instanceHoldsLibusb(&sup.managed.items[0])); + try testing.expect(!Supervisor.managedInstanceAlive(&sup.managed.items[0])); + + sup.sweepLivenessLibusb(); + try testing.expectEqual(@as(usize, 1), sup.managed.items.len); + try testing.expect(sup.devname_map.contains("hidraw0")); +} + test "supervisor: Supervisor: global SWITCH rolls back all devices on failure" { const allocator = testing.allocator;