Skip to content

Fix/57 macos iosetelement not found#61

Merged
steffen-wilke merged 10 commits into
mainfrom
fix/57-macos-iosetelement-not-found
Jun 2, 2026
Merged

Fix/57 macos iosetelement not found#61
steffen-wilke merged 10 commits into
mainfrom
fix/57-macos-iosetelement-not-found

Conversation

@steffen-wilke

@steffen-wilke steffen-wilke commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #57: macOS IOKit plugin throws IllegalStateException and floods log with "IOHIDElement not found" warnings.

Root cause

  1. setDevices(...) was called after CFRunLoopRun() blocked, so callers of init() could observe IllegalStateException from getAll().
  2. The input value callback identified elements by raw IOHIDElementRef pointer, which is unreliable when the FFM downcall layer hands back a MemorySegment carrier — causing the WARNING flood.
  3. The IOHIDManager was matching all HID devices (keyboards, mice, trackpads), not just gamepads.

Changes

  • Async init: replace volatile boolean + Thread.sleep(100) polling with CountDownLatch; count down in catch so failures don't hang.
  • Cookie-based lookup: bind IOHIDElementGetCookie, store on IOHIDElement, resolve values via (deviceAddress, cookie) index in hidInputValueCallback. Demote unknown-element log to FINE.
  • Match gamepad/joystick only: build a CFDictionary restricting kIOHIDPrimaryUsagePage to 0x01 and kIOHIDPrimaryUsage to [joystick, gamepad, multi-axis controller].
  • Fix CFIndex width: CFArrayGetCount / CFSetGetCount / CFArrayGetValueAtIndex use JAVA_LONG, not JAVA_INT.
  • Resource hygiene: CFRelease the IOHIDManagerCopyDevices set and the per-device IOHIDDeviceCopyMatchingElements array.
  • FFM lifetime safety: replace confined Arena (closed at end of static block) with Arena.global() for the CoreFoundation callback-struct lookups, so the stored MemorySegments live for the JVM lifetime.
  • Graceful shutdown: close() now calls CFRunLoopStop on the captured run loop, joins the event-loop thread (3 s timeout), then super.close() — no more JVM hangs on a blocked CFRunLoopRun.
  • Refactor: extract package-private populateDevices(...) for cross-platform unit testing; fold setDevices into it. Singleton-ify the matching dict. initLatch → local.
  • Thread-safety: IOHIDElement.currentValue is now volatile (was a JLS 17.4 data race between CFRunLoop writer and polling reader).

- Move setDevices(...) before MacOS.runEventLoop(...) so the devices
  are observable to the caller of init() the moment discovery completes
  (previously, setDevices was unreachable because CFRunLoopRun blocks
  indefinitely).
- Replace the volatile boolean + Thread.sleep(100) polling wait with a
  CountDownLatch awaited with a 3 second timeout, removing the
  happens-before ambiguity from the previous approach.
- Decrement the latch from the catch block too so callers do not block
  forever on init failures.
…ion objects

- Demote the 'IOHIDElement not found' message in hidInputValueCallback
  from WARNING to FINE. Reporting elements that belong to unregistered
  HID devices (keyboard, trackpad, etc.) is informational, not an
  error, and was flooding the user's log output.
- Release the CFSet returned by IOHIDManagerCopyDevices and the
  CFArray returned by IOHIDDeviceCopyMatchingElements via CFRelease
  in a finally block. Both follow the Create Rule and were leaked
  on every discovery, causing slow but real growth across long
  sessions.
…CFIndex width

- Build a CFDictionary matching criteria for IOHIDManager that restricts
  enumeration to HID devices on the Generic Desktop usage page whose
  primary usage is joystick (0x04) or gamepad (0x05). Without this, the
  HID manager opens with a NULL matching dict, which means 'all
  devices', causing the input value callback to fire for keyboards,
  mice, trackpads and other unrelated HID hardware.
- Add the necessary CoreFoundation bindings: CFDictionaryCreateMutable,
  CFDictionarySetValue, CFArrayCreateMutable, CFArrayAppendValue and
  CFNumberCreate.
- Resolve the addresses of kCFTypeDictionaryKeyCallBacks,
  kCFTypeDictionaryValueCallBacks and kCFTypeArrayCallBacks at static
  init time via dlsym on the CoreFoundation framework, so we do not
  depend on hard-coded addresses that change between macOS releases.
- Change the CFArrayGetCount, CFSetGetCount (return) and
  CFArrayGetValueAtIndex (second arg) function descriptors from
  JAVA_INT to JAVA_LONG, because CFIndex is typedef long on 64-bit
  macOS. The previous signatures would silently truncate any count
  over 2^31 and on arrays over 2^31 elements.
Pull the InputDevice / component assembly logic out of
internalInitDevices into a package-private populateDevices(Collection)
method. The discovery call (getSupportedHIDDevices) still requires
native libs to run, but the assembly logic is pure Java and can now be
unit-tested by passing in hand-built IOHIDDevice fixtures on any
platform.
…ointer

Adds IOHIDElementGetCookie binding and stores the cookie on IOHIDElement
during discovery. The hidInputValueCallback now resolves the originating
element by (deviceAddress, cookie) instead of dereferencing the sender
pointer, which is unsafe across the FFM boundary.

- MacOS: IOHIDElementGetCookie downcall handle
- IOHIDElement: int cookie field with javadoc
- IOKitPlugin: Map<Long, Map<Integer, IOHIDElement>> indexed by device
  address and cookie; populateDevices builds the index, findElement
  queries it, close() clears it.
- hidInputValueCallback: resolve element via findElement(sender.address(),
  MacOS.IOHIDElementGetCookie(element)). If the element cannot be
  resolved, log at FINE and return (no WARNING flood).
- IOKitPluginTests: covers populateDevices (registers devices/components,
  skips UNDEFINED for components but still indexes them), findElement
  (cookie+device lookup, isolation across devices, null for unknown
  cookie/device, cleared on close).
… fold setDevices into populateDevices

- MacOS: the static initializer looked up kCFType*CallBacks via a confined
  Arena that closed at the end of the block, leaving the three stored
  MemorySegments backed by a closed scope. They worked only by accident
  (raw pointer pass-through and framework refcount > 0). Replace with a
  static Arena.global() so the segments live for the JVM lifetime.
- IOKitPlugin: move the setDevices call from internalInitDevices into
  populateDevices so the helper is a complete discover+publish unit.
- IOKitPluginTests: drop the publishDevices reflection helper now that
  populateDevices itself publishes the devices.
- IOKitPlugin: remove the unused MacOS.IOHIDValueGetTimeStamp call in
  hidInputValueCallback.
- MacOS: fix IOHIDElementGetCookie Javadoc - the method throws on
  failure, it does not return 0 as a sentinel.
- IOKitPluginTests: reorder imports to match AGENTS.md (java.*, external,
  de.gurkenlabs.*, static).
- MacOS: include kHIDUsage_GD_MultiAxisController (0x08) in the
  IOHIDManager matching dict so flight sticks, 3D mice, and some
  racing wheels are picked up alongside joysticks and gamepads.
- MacOS: build the matching dictionary once at class init and reuse
  it across initHIDManager invocations. Removes the per-init
  CFArray / CFDictionary / CFNumber allocations and the try/finally
  release dance.
- IOKitPlugin: move the initLatch CountDownLatch from a field to a
  local in internalInitDevices. The field was never read by anything
  other than the initialization flow itself.
… join

CFRunLoopRun is a native C function that blocks on a Mach port and is
not responsive to Thread.interrupt(). The previous close() called
eventLoopThread.interrupt() and returned, leaving the run-loop thread
blocked indefinitely. JVM shutdown would hang waiting for the thread;
re-initializing the plugin would leak a thread with a live HID manager.

- MacOS: add CFRunLoopStop downcall handle and helper.
- MacOS: add CFRunLoopGetCurrent helper to expose the calling thread's
  CFRunLoopRef through the same try/catch wrapper as the other helpers.
- IOKitPlugin: capture this thread's CFRunLoopGetCurrent() into a
  volatile eventLoopRunLoop field immediately before initLatch.countDown
  so close() can find it. The CountDownLatch's happens-before edge
  guarantees the main thread sees the captured ref.
- IOKitPlugin.close: call CFRunLoopStop on the captured run loop, join
  the event-loop thread with a 3 s timeout, then super.close() and clear
  the maps. The event-loop thread is guaranteed not to write to
  IOHIDElement.currentValue or the cookie index by the time super.close()
  runs.
The CFRunLoop thread writes currentValue in hidInputValueCallback and
the polling thread reads it in pollIOHIDDevice. Per JLS 17.4 the read
is not guaranteed to see the most recent write without a happens-
before edge. The fix is the volatile keyword on the field.
@sonarqubecloud

sonarqubecloud Bot commented Jun 2, 2026

Copy link
Copy Markdown

@steffen-wilke steffen-wilke merged commit 29b9641 into main Jun 2, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cannot find the correct native device on macos

1 participant