|
| 1 | +# Concurrency and Robustness Review — Windows Implementation |
| 2 | + |
| 3 | +Review of the library's concurrency behavior, focused on the Windows implementation. |
| 4 | +Threads involved: user threads, the "USB async IO" completion thread (`WindowsAsyncTask`), |
| 5 | +and the "USB device monitor" thread (window message loop in `WindowsUsbDeviceRegistry`). |
| 6 | + |
| 7 | +Findings are ordered by severity. |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## 1. Potential permanent deadlock: completion handlers are invoked while holding the `WindowsAsyncTask` monitor — **FIXED (Windows & Linux)** |
| 12 | + |
| 13 | +> **Status:** Fixed on 2026-07-03. `WindowsAsyncTask.completeTransfer` now removes the transfer |
| 14 | +> from the map, copies the results out of the OVERLAPPED, and recycles the OVERLAPPED under the |
| 15 | +> lock, then invokes the completion handler after releasing it, wrapped in try/catch so a throwing |
| 16 | +> handler cannot kill the async IO thread (partially addressing finding 3 as well). Verified with |
| 17 | +> the full hardware test suite (59 tests, all passing). macOS was already correct. |
| 18 | +> |
| 19 | +> The Linux counterpart is fixed the same way: `reapURBs` now reaps and unlinks transfers under the |
| 20 | +> task monitor, collecting them into a list, and invokes the handlers after releasing it (in a |
| 21 | +> `finally`, so already-reaped transfers still complete if reaping throws), each wrapped in |
| 22 | +> try/catch. `removeFromAsyncIOCompletion` is no longer method-synchronized: the epoll removal and |
| 23 | +> the stale-URB sweep each take the lock briefly, and the ENODEV completions are invoked outside |
| 24 | +> it. This is safe against a concurrent submit on the same device because `LinuxUsbDevice.close()` |
| 25 | +> and `LinuxUsbDevice.submitTransfer` serialize on the device monitor. Compile-verified only (see |
| 26 | +> finding 2's note on Linux verification). |
| 27 | +> |
| 28 | +> **Residual (pre-existing, Linux):** the ENODEV completions in `removeFromAsyncIOCompletion` run |
| 29 | +> on the closing thread, which holds the *device* monitor (`LinuxUsbDevice.close()` is |
| 30 | +> synchronized). A timed-out waiter holding a transfer monitor and calling `abortTransfers` (wants |
| 31 | +> the device monitor) while `close()` invokes that transfer's handler (wants the transfer monitor) |
| 32 | +> could still cycle. This inversion predates this fix and no longer involves the async IO thread, |
| 33 | +> so it can only stall the closing thread and the waiter, not the whole library. Windows doesn't |
| 34 | +> have it (close-triggered completions arrive via the IO thread, which holds no device monitor). |
| 35 | +
|
| 36 | +`WindowsAsyncTask.completeTransfer()` (`WindowsAsyncTask.java:157`) is `synchronized` and calls |
| 37 | +`transfer.completion().completed(transfer)` inside the lock. This creates a lock-order inversion |
| 38 | +with the user-thread paths: |
| 39 | + |
| 40 | +- **User thread A** (sync transfer with timeout): holds the `transfer` monitor |
| 41 | + (`synchronized (transfer)` block in `WindowsUsbDevice.transferOut`, `WindowsUsbDevice.java:338`) |
| 42 | + and, when the timeout fires, calls `abortTransfers(...)` from inside `waitForTransfer` |
| 43 | + (`UsbDeviceImpl.java:385`) → **wants the device monitor** (`abortTransfers` is `synchronized`, |
| 44 | + `WindowsUsbDevice.java:482`). |
| 45 | +- **User thread B**: holds the device monitor (`submitTransferOut` is `synchronized`, |
| 46 | + `WindowsUsbDevice.java:413`) and calls `asyncTask.prepareForSubmission(...)` → |
| 47 | + **wants the asyncTask monitor**. |
| 48 | +- **Async IO thread**: holds the asyncTask monitor in `completeTransfer` and calls |
| 49 | + `onSyncTransferCompleted` → **wants A's transfer monitor** (`UsbDeviceImpl.java:477`). |
| 50 | + |
| 51 | +If A's transfer completes right as its timeout expires (exactly the window in which timeouts race |
| 52 | +with completions), the cycle A→device→B, B→asyncTask→IO thread, IO thread→transfer→A closes and is |
| 53 | +permanent. Because `WindowsAsyncTask` is a process-wide singleton, a stuck IO thread freezes |
| 54 | +completions for **all** devices, and every no-timeout transfer then blocks forever in |
| 55 | +`waitNoTimeout`. The trigger is realistic: one thread doing timed transfers while another thread |
| 56 | +submits on the same or another device. |
| 57 | + |
| 58 | +**Fix:** in `completeTransfer`, do the map removal / result copying / OVERLAPPED recycling under |
| 59 | +the lock, but invoke the completion handler *after* releasing it. (The macOS `MacosAsyncTask` has |
| 60 | +the same structural pattern and is worth the same check.) |
| 61 | + |
| 62 | +## 2. Failed submission leaks the transfer registration — the Windows counterpart of the macOS fix is missing — **FIXED (Windows & Linux)** |
| 63 | + |
| 64 | +> **Status:** Fixed on 2026-07-03. `WindowsAsyncTask.submissionFailed(transfer)` now undoes the |
| 65 | +> registration when a `WinUsb_*` call fails synchronously: it removes the map entry, returns the |
| 66 | +> OVERLAPPED to the pool, and clears the transfer's reference (safe because Win32 posts no |
| 67 | +> completion packet for a synchronous failure). Called from the error paths of all three submit |
| 68 | +> methods, mirroring the macOS fix. Verified with the full hardware test suite (59 tests, all |
| 69 | +> passing). |
| 70 | +> |
| 71 | +> The same leak was discovered and fixed in Linux: `LinuxAsyncTask.submitTransfer` called |
| 72 | +> `linkToUrb` (registers the URB→transfer mapping and takes a URB from the pool) and then threw on |
| 73 | +> `SUBMITURB` ioctl failure without cleanup. A private `submissionFailed(transfer)` (safe to call |
| 74 | +> under the already-held task monitor) now unlinks the transfer and recycles the URB — safe |
| 75 | +> because a URB rejected by the ioctl is never queued and will never be reaped. Compile-verified |
| 76 | +> only; the hardware test suite was run on Windows and does not exercise the Linux code path. |
| 77 | +
|
| 78 | +Commit `60a47af` ("Deregister transfer on exception (macOS)") added |
| 79 | +`asyncTask.submissionFailed(transfer)` when an async submission fails synchronously. Windows has |
| 80 | +the identical bug, unfixed: in `submitControlTransfer`, `submitTransferOut`, and `submitTransferIn` |
| 81 | +(`WindowsUsbDevice.java:404–408, 422–426, 440–444`), `asyncTask.prepareForSubmission(transfer)` |
| 82 | +registers the OVERLAPPED→transfer mapping and takes an OVERLAPPED from the pool; if `WinUsb_*` then |
| 83 | +fails with anything other than `ERROR_IO_PENDING`, the exception path leaves the entry in |
| 84 | +`requestsByOverlapped` forever and the OVERLAPPED never returns to `availableOverlappedStructs`. |
| 85 | + |
| 86 | +Synchronous failures are the *normal* case right after an unplug, so an application that keeps |
| 87 | +retrying on a hot-unplugged device leaks an OVERLAPPED, a map entry, and the pinned transfer buffer |
| 88 | +per attempt. |
| 89 | + |
| 90 | +**Fix:** add the same `submissionFailed()` cleanup to `WindowsAsyncTask` and call it from the three |
| 91 | +submit methods' error paths. |
| 92 | + |
| 93 | +## 3. The singleton async IO thread can die, silently hanging the whole library — **FIXED (Windows & Linux)** |
| 94 | + |
| 95 | +> **Status:** Fixed on 2026-07-03 (handler-exception protection was already added with finding 1; |
| 96 | +> macOS needed nothing — its upcall is fully wrapped and a run loop exit is logged). |
| 97 | +> |
| 98 | +> **Windows:** the completion loop body is wrapped in try/catch. Any exception — including a |
| 99 | +> `GetQueuedCompletionStatus` failure with a null OVERLAPPED, whose error text is also fixed — |
| 100 | +> now logs an ERROR, fails every transfer in `requestsByOverlapped` with |
| 101 | +> `ERROR_OPERATION_ABORTED` (handlers invoked outside the lock, individually guarded), marks the |
| 102 | +> task terminated, and exits. `prepareForSubmission` rejects new submissions with a clear |
| 103 | +> `UsbException` once terminated, so callers fail fast instead of hanging in `waitNoTimeout`. |
| 104 | +> The silent `return` on a successful dequeue with null OVERLAPPED ("registry closing?") was |
| 105 | +> speculative — nothing posts such packets — and now goes through the same fatal path. |
| 106 | +> |
| 107 | +> **Linux:** same structure — the epoll loop body is wrapped; a non-EINTR `epoll_wait` failure |
| 108 | +> (or an escaping exception) fails all pending transfers with `ECANCELED`, marks the task |
| 109 | +> terminated, and exits; `submitTransfer` then rejects new work. In addition, two per-device reap |
| 110 | +> failures no longer kill the thread: `EBADF` is treated as a benign race (the fd was closed |
| 111 | +> concurrently — reachable from an ordinary `close()` while the event thread already holds a |
| 112 | +> ready event) and any other unexpected reap errno degrades only that device (log, deregister |
| 113 | +> from epoll to avoid a hot loop, continue serving other devices). |
| 114 | +> `EPoll.removeFileDescriptor` now tolerates `EBADF` like `ENOENT`. `EBADF`/`ECANCELED` were |
| 115 | +> added to the committed jextract errno bindings by hand (asm-generic values, valid on |
| 116 | +> x64/ARM64) and to `gen_linux.sh` for the next regeneration on a Linux machine. |
| 117 | +> |
| 118 | +> Verified with the full hardware test suite on Windows (59 tests, all passing); the fatal paths |
| 119 | +> themselves and the Linux changes are verified by inspection/compilation only. |
| 120 | +
|
| 121 | +Two ways `asyncCompletionTask()` (`WindowsAsyncTask.java:68`) can terminate: |
| 122 | + |
| 123 | +- `GetQueuedCompletionStatus` fails with a null OVERLAPPED → `throwLastError` (line 86) kills the |
| 124 | + thread. |
| 125 | +- Any `RuntimeException` escaping a completion handler — handlers run inline in this thread (see |
| 126 | + finding 1), so a bug or unexpected state in stream/user completion code is fatal to the loop. |
| 127 | + |
| 128 | +There is no restart, and the failure mode is nasty: subsequent transfers submit fine but never |
| 129 | +complete, so callers block forever in `waitNoTimeout` (unbounded) and |
| 130 | +`flush()`/`waitForAvailableTransfer` (also unbounded by design). |
| 131 | + |
| 132 | +**Fix:** wrap the loop body in a catch-log-continue (a failed handler must not stop dispatching |
| 133 | +other devices' completions) and reserve thread death for truly unrecoverable port errors — ideally |
| 134 | +then failing all pending transfers in `requestsByOverlapped` so waiters wake up. |
| 135 | + |
| 136 | +Minor: the error text at line 86 says "SetupDiGetDeviceInterfaceDetailW" — a copy-paste from |
| 137 | +elsewhere. |
| 138 | + |
| 139 | +## 4. `claimInterfaceSynchronized` leaves inconsistent state on error paths |
| 140 | + |
| 141 | +In `WindowsUsbDevice.java:215–217`, `firstIntfHandle.deviceHandle` and `winusbHandle` are assigned |
| 142 | +**before** `asyncTask.addDevice(deviceHandle)`. If `addDevice` throws (CreateIoCompletionPort |
| 143 | +failure), the catch closes the device handle but leaves both fields set — a later claim sees |
| 144 | +`deviceHandle != null`, skips reopening, and submits I/O on a closed handle. |
| 145 | + |
| 146 | +**Fix:** assign the fields only after `addDevice` succeeds (or null them in the catch, and |
| 147 | +`WinUsb_Free` the interface handle too). |
| 148 | + |
| 149 | +Related: when claiming an associated interface (e.g. interface 1 of a function starting at 0) opens |
| 150 | +the device and then `WinUsb_GetAssociatedInterface` fails (line 228), the exception propagates with |
| 151 | +the device left open but `deviceOpenCount == 0` and no interface claimed. Since `close()` only |
| 152 | +releases *claimed* interfaces, that device handle (still registered with the completion port) leaks |
| 153 | +until process exit. |
| 154 | + |
| 155 | +## 5. Visibility: shared flags are neither volatile nor consistently synchronized |
| 156 | + |
| 157 | +- `showAsOpen`: written under the device monitor, but `isOpened()` (`WindowsUsbDevice.java:114`) |
| 158 | + reads it unlocked — `checkIsOpen`/`checkIsClosed` can act on stale state on a different thread. |
| 159 | +- `UsbDeviceImpl.connected`: written by the monitor thread in `disconnect()`, read unlocked via |
| 160 | + `isConnected()` and `checkIsClosed`. |
| 161 | +- `UsbDeviceRegistry.onDeviceConnectedHandler` / `onDeviceDisconnectedHandler`: set by the app |
| 162 | + thread, read by the monitor thread with no happens-before edge — a handler registered after |
| 163 | + `start()` may never be seen. |
| 164 | + |
| 165 | +All are cheap to fix with `volatile`. Practical impact is low (stale reads, not corruption), but |
| 166 | +`isConnected()` returning `true` long after unplug is user-visible. |
| 167 | + |
| 168 | +## 6. Minor observations |
| 169 | + |
| 170 | +- **Data race on result fields in the timeout path**: `completeTransfer` writes |
| 171 | + `resultCode`/`resultSize` before taking the transfer monitor, while the timed-out waiter reads |
| 172 | + `transfer.resultCode()` holding it (`UsbDeviceImpl.java:384`). Consequences are benign (a |
| 173 | + redundant `WinUsb_AbortPipe`, or a timeout exception despite late data — inherent to the race |
| 174 | + anyway), but it's formally a data race. |
| 175 | +- **User callbacks run on the monitor thread inside the window procedure** |
| 176 | + (`WindowsUsbDeviceRegistry.java:326–347`). A slow `onDeviceConnected`/`onDeviceDisconnected` |
| 177 | + handler stalls all further device notifications (and the `disconnect()` cleanup of other |
| 178 | + devices). Exceptions are caught, which is good; the latency constraint is worth documenting if it |
| 179 | + isn't already. |
| 180 | +- `completeTransfer` dispatches all devices through one lock and one thread, so one slow handler |
| 181 | + delays every device's completions — currently fine since internal handlers are cheap queue |
| 182 | + operations, but it compounds finding 1's argument for invoking handlers outside the lock. |
| 183 | + |
| 184 | +## What holds up well |
| 185 | + |
| 186 | +The overall unplug/teardown story is sound: closing the WinUSB/file handles cancels pending I/O, |
| 187 | +cancelled I/O still posts completion packets to the port, so blocked waiters wake with an error |
| 188 | +result; the stream teardown paths (`close()`, `collectOutstandingTransfers`, abort-completion wait) |
| 189 | +are all deadline-bounded so a genuinely lost completion degrades to a logged warning rather than a |
| 190 | +hang — only the *non*-teardown unbounded waits remain exposed, and only via finding 3. |
| 191 | + |
| 192 | +The `claimInterface` retry loop sleeps outside the device monitor, interrupts are deferred rather |
| 193 | +than swallowed, `transfer.wait()` correctly releases the transfer monitor while submissions on |
| 194 | +other threads proceed, and the copy-on-write device list with the case-insensitive Windows override |
| 195 | +applied to both add and remove paths is correct. The timed-out-transfer buffers using |
| 196 | +`Arena.ofAuto()` so an abandoned transfer can't have its buffer freed under a late completion is a |
| 197 | +well-handled detail. |
| 198 | + |
| 199 | +## Recommended priority |
| 200 | + |
| 201 | +Findings 1, 2 and 3 are fixed on all platforms (see above). Next up: finding 4 |
| 202 | +(`claimInterfaceSynchronized` error-path state), then the `volatile` flags of finding 5. |
| 203 | +The Linux fixes for findings 1–3 still need a hardware test run on a Linux machine. |
0 commit comments