Netlink uevent hotplug monitor (mechanism + headless diagnostic) - #8
Merged
Conversation
There's no libudev in the webOS app jail, so the joystick and hidapi backends fall back to rescanning /dev every 3 seconds and diffing a presence bitmask. That's slow to react, and it cannot see a controller that disconnects and reconnects on the same index between two scans, because the bitmask comes out unchanged. Bind a NETLINK_KOBJECT_UEVENT socket instead. The kernel broadcasts add/remove to group 1 with no privilege and no libudev, events are explicit and ordered rather than inferred from a bitmask diff, and they arrive in well under 100ms. This adds the mechanism and a way to measure it. Neither backend is wired up to it yet. Each subsystem is expected to open its own monitor: netlink delivers a copy to every bound socket, whereas sharing one fd would mean whichever side drained it first consumed the other's events. testwebosuevent runs the monitor and the presence poll side by side over the same hotplug activity and reports whether netlink can replace the poll. Unprivileged bind is verified on webOS 10 (kernel 5.4) but not on the 3.10 kernels older versions ship, and that is the open question standing between this and dropping the poll. It reports INCONCLUSIVE rather than a pass when no device came or went, since an idle system produces no uevents whether or not the socket is delivering. That one needs hardware to conclude anything, so the message parsing is covered separately by testwebosuevent_parse, which runs non-interactively under ctest against synthetic uevents. Both follow testevdev: internal sources are compiled in rather than linked, since they're unexported, and SDL_internal.h comes first so the dynapi renaming applies to the public declarations as well as to our calls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mariotaku
force-pushed
the
feature/netlink-uevent-monitor
branch
from
August 16, 2026 07:55
d1b318b to
469fb00
Compare
…ting Netlink is lossy under pressure: rather than blocking the sender, the kernel discards broadcasts and reports ENOBUFS once. Poll() treated that exactly like EAGAIN, so a caller could not tell "socket drained" from "the kernel threw events away", and would carry on believing its device list was in sync. Since a webOS app can be backgrounded or suspended -- which is precisely how the buffer fills while controllers come and go -- that would leave the list permanently stale, strictly worse than the polling this replaces, which self-heals within one interval. Report it instead, via SDL_webOSUeventMonitorLostEvents(), so a backend can respond with the same full scan it does at init. Draining stops at the overflow: a rescan supersedes whatever is still queued, and returning avoids spinning if the condition repeats. This also fixes how testwebosuevent counts what the poll misses. It only flagged an index when the bitmask did not change at all, which catches a reconnect (2 transitions, 0 expressible) but not a whole connect/ disconnect cycle landing between two scans (3 transitions, 1 expressible). It now compares transitions seen against transitions the diff could express, per index, so both collapse the same way. On hardware the corrected count matches the raw event difference exactly: 46 netlink transitions against 34 poll changes, 12 reported lost. The seeding scan is quiet now as well. It ran through the reporting path, so every already-attached device printed as an arrival netlink had failed to report -- 21 alarming lines before the run even started. Tests need __WEBOS__ on webOS builds. SDL_dynapi.h keys SDL_DYNAMIC_API off it, and the library gets it from sdl-build-options while test targets do not, so without it the test compiles against SDL_*_REAL while the library defines the plain names and fails to link. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both backends now take hotplug from the netlink stream instead of rescanning /dev on a timer. LINUX_JoystickDetect drains a monitor rather than diffing a presence bitmask, and the hidapi discovery loses its separate 3-second hidraw scan. The monitor is self-contained, so using it is no harder than the poll it replaces: open one for a node class and drain it wherever you used to poll. Open() binds the socket and only then enumerates what's already attached, queueing those as ordinary add events -- callers get one code path for existing and hotplugged devices, and the bind-before-scan ordering is handled here rather than left to each integration to remember. Poll() filters to the requested class, collapses events that would not change what the caller believes, and hands back a devnode ready for MaybeAddDevice(). Overflow recovery moved inside for the same reason. It was exposed as LostEvents(), which only worked if the caller remembered to ask; forget it and the device list goes permanently stale, silently. The monitor now re-enumerates on ENOBUFS and reports the difference against what it last told the caller, so the caller sees a correct add/remove stream either way. Polling stays for the cases that need it: a socket that will not open, and the re-enumeration behind the overflow recovery. Measured inside the app jail across three kernel generations -- webOS 3.4 (3.10.19), 4 (4.4.84) and 10 (5.4.268). Over 111 poll-detected changes netlink missed none, while the poll lost 23 transitions netlink caught, including whole connect/disconnect cycles that landed between two scans and left the bitmask unchanged. testwebosuevent grows a --sdl mode that reports SDL's own joystick device events, which checks the backend wiring rather than the stream underneath it. On a webOS 3.4 TV it shows two removes 35ms apart and several add/remove pairs under a second, all resolved individually -- none of which the 3-second poll could express. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mariotaku
marked this pull request as ready for review
August 17, 2026 04:08
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces the 3-second joystick and hidraw presence polls on webOS with kernel uevents. Both backends are wired up.
Background: https://gist.github.com/mariotaku/131ed7ba5a9bbdfc3a1dbb1261feb174 — context is moonlight-tv#614, which carries the reconnect workaround as a build-time patch. This should let that PR drop the patch file, the apply step, and the revision pin.
Problem
No libudev in the app jail, so
enumeration_methodfalls back toENUMERATION_POLLING. Every 3sLINUX_FallbackJoystickDetectrescans/dev/inputand diffs a 32-bit presence bitmask. A controller that disconnects and reconnects on the same index between two polls leaves the bitmask unchanged, so SDL never notices.Two things beyond the gist:
MaybeAddDevicefor present indices — neverMaybeRemoveDevice. Plain disconnects weren't reported by this path either.src/hidapi/SDL_hidapi.cdoing its own hidraw bitmask diff. A DualShock 4 goes through HIDAPI, so fixing only the joystick side would have left half the latency in place. Both are gone now.Using it
The monitor is self-contained — no harder than the poll it replaces:
Open()binds the socket and only then enumerates what's already attached, queueing those as ordinary add events — so callers have one code path for existing and hotplugged devices, and the bind-before-scan ordering is handled internally rather than left to each integration to remember.Poll()filters to the requested node class, collapses events that wouldn't change what the caller believes, and returns adevnodeready forMaybeAddDevice().Overflow recovery is internal for the same reason. Netlink is lossy: the kernel discards broadcasts under pressure and reports
ENOBUFSonce. An earlier revision exposed this asLostEvents(), which only worked if the caller remembered to ask — and forgetting left the device list permanently stale, silently, which is worse than the polling it replaces. The monitor now re-enumerates onENOBUFSand reports the difference against what it last told the caller. This matters on webOS because an app can be backgrounded or suspended, and a process not draining while devices come and go is exactly how the buffer fills.Other design points:
DEVNAMEpreferred overDEVPATH, falling back toDEVPATH's trailing component since aremoveon an older kernel can omitDEVNAME.MaybeAddDevice.Polling stays for exactly two cases: a socket that won't open, and the re-enumeration behind overflow recovery.
scandir+statsweep every 3 srecv, usuallyEAGAINHardware results
Measured with
testwebosuevent, running the monitor and the presence poll side by side over the same hotplug activity. webOS 4 and 3.4 ran inside a realnative_devmodejail viajailer, as the app's unprivileged uid.111 poll-detected changes across three kernel generations. Netlink missed none. The poll lost 23 transitions netlink caught.
remove hidraw/0at 62.575,add hidraw/0at 62.756 — 181 ms apart, both between two polls, bitmask byte-identical across them.netlink saw 2 transition(s), the bitmask could express 0.End to end, through SDL's own API
testwebosuevent --sdlreportsSDL_JOYDEVICEADDED/REMOVEDinstead, which checks the backend wiring rather than the stream underneath it. On the UH6100 (kernel 3.10), inside the jail:Five distinct transitions inside two 3-second windows, each surfaced as its own SDL event — the old code could emit at most one bitmask diff per window. The two controllers already attached at startup arrive as ordinary
SDL_JOYDEVICEADDEDat 1.390 s, which is the monitor's internal enumeration.Over the run: 7 adds, 5 removes, 2 present at exit. The accounting balances exactly, so 12 transitions of aggressive hotplugging left no leaked or phantom entries in SDL's device list.
Tests
Both follow the
testevdevpattern: internal sources are compiled in rather than linked (they're unexported), andSDL_internal.hcomes first so the dynapi renaming applies to the public declarations as well as our own calls. On webOS builds the test targets also need__WEBOS__—SDL_dynapi.hkeysSDL_DYNAMIC_APIoff it, and the library gets it fromsdl-build-optionswhile tests don't, so without it the test referencesSDL_*_REALwhile the library defines plain names and fails to link.test/testwebosuevent_parse(non-interactive, ctest): nine synthetic uevents — evdev add, remove withoutDEVNAME, hidraw,change/bindrejection, libudev-format rejection, missingACTION, trailing-slash path, index past 31.test/testwebosuevent(needs hardware): headless, meant to run over SSH on a TV. Cross-checks netlink against the poll and reportsNETLINK USABLE/FALLBACK REQUIRED/INCONCLUSIVE, the latter because an idle system produces no uevents whether or not the socket works.--sdlswitches to reporting SDL's own device events.Not gated on
WEBOS— the mechanism is plain Linux netlink, so it runs on a desktop with a USB controller too.On the fallback trigger
The gist suggests falling back to polling if no uevent arrives within a settle window after the initial scan. I'd argue against that specific rule: absence of events isn't evidence of breakage, since an idle TV legitimately produces none. The runs bear this out — netlink was silent for 45 s at a stretch while working perfectly.
Open()failing plusENOBUFScovers what's actually detectable.Open question
On the C5, netlink reported
hidraw/1add and remove that the presence poll never saw in either direction, whilehidraw/0tracked correctly. Most likely that jail's/devdidn't contain the node. It didn't reproduce on the other two TVs. So netlink can report devices the jail can't open —MaybeAddDeviceopens the path and fails gracefully, so this should be safe, but it's the one behaviour I'd want a second opinion on.🤖 Generated with Claude Code