Skip to content

Harden the USB adapter lifecycle - #116

Merged
vertexodessa merged 4 commits into
OpenIPC:masterfrom
iflyhere:fix/usb-adapter-lifecycle
Sep 2, 2026
Merged

Harden the USB adapter lifecycle#116
vertexodessa merged 4 commits into
OpenIPC:masterfrom
iflyhere:fix/usb-adapter-lifecycle

Conversation

@iflyhere

@iflyhere iflyhere commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Note

Compile tested only (arm64-v8a + armeabi-v7a). The happy path is unchanged;
what changes is what happens when the adapter is not there. Testing with a
hub that re-enumerates the dongle, and with the permission dialog dismissed,
would be the useful check.

Four separate ways the adapter path can take the app down or wedge it. All of
them are easy to hit on a powered OTG hub that re-enumerates the dongle, which is
how a lot of ground stations are wired — and a crash here means going blind mid
flight.

1. Deliberate null dereference

WfbngLink.cpp defines

#define CRASH()                 \
    do {                        \
        int *i = 0;             \
        *i = 42;               \
    } while (0)

and runs it in WfbngLink::stop() when the fd is no longer in rtl_devices.
That is a recoverable state — the adapter was already gone — and it kills the
process. Removed; now a warning and a return.

2. NPE on openDevice()

UsbDeviceConnection usbDeviceConnection = usbManager.openDevice(usbDevice);
int fd = usbDeviceConnection.getFileDescriptor();

openDevice() returns null when the permission was revoked or the device
disappeared between hasPermission() and here. WfbNgLink.start() now returns
boolean, and WfbLinkManager.startAdapter() reports the failure instead of
crashing.

That also fixes a second-order bug: refreshAdapters() used to add the device to
activeWifiAdapters unconditionally, so an adapter that failed to start was
recorded as running and never retried on a later refresh. It is only tracked now
if it actually came up.

3. Leaked usbfs descriptors

UsbDeviceConnection was never close()d and linkConns was never cleared, so
every attach/detach cycle leaked one file descriptor plus the map entry. Both
stop() and stopAll() now close and remove.

4. USB permission dialog on Android 14

PendingIntent.getBroadcast(context, 0,
        new Intent(WfbLinkManager.ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE);

The intent is implicit. Since Android 14 a PendingIntent built from an implicit
intent is not delivered to a runtime-registered receiver, so the permission
result never arrives and the app sits on "No permission for wifi adapter(s)"
even after the user granted it. setPackage(context.getPackageName()) added.

Also

  • refreshAdapters() dereferenced getAttachedAdapters() without checking the
    null it returns when usb_device_filter.xml fails to parse
  • the wfb thread name indexed split("/dev/bus/usb/")[1] without checking the
    device name actually matched

Not in this PR

The wfb-ng RX thread is a plain new Thread(...) at default priority, even
though it is the thread pumping libusb. Giving it a realtime-ish priority is
probably worth doing, but it is a behaviour change that deserves its own PR.


Part of a series of independent fixes found while building an immersive (OpenXR) mode on a
Quest 3, each standalone and mergeable in any order:

#113 and #116 are now confirmed on hardware (Quest 3, Horizon OS, Android 14).

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Harden USB adapter lifecycle and recovery

🐞 Bug fix 🕐 20-40 Minutes

Grey Divider

AI Description

• Gracefully handles missing, revoked, or re-enumerated USB adapters without process crashes.
• Retries failed adapter starts and fixes Android 14 permission-result delivery.
• Closes USB connections and clears lifecycle state during individual and bulk shutdown.
Diagram

sequenceDiagram
    participant B as USB Broadcast
    participant M as Link Manager
    participant U as USB Manager
    participant J as Java Link
    participant N as Native Link
    B->>M: Refresh adapters
    M->>U: Check permission
    alt Permission missing
        M->>U: Request explicit intent
    else Permission granted
        M->>J: Start adapter
        J->>U: Open device
        alt Open succeeds
            J->>N: Run with fd
            M-->>M: Track active
        else Open fails
            J-->>M: Return false
        end
    end
    B->>M: Detach refresh
    M->>J: Stop adapter
    J->>N: Stop fd
    J->>U: Close connection
Loading
High-Level Assessment

The scoped approach is appropriate: propagate start success through the existing manager boundary, retain connection ownership in the Java link wrapper, and make native stop idempotent for already-removed devices. A broader adapter-session state machine could centralize lifecycle state, but it would add behavioral scope without improving these targeted recovery fixes.

Files changed (3) +49 / -15

Bug fix (3) +49 / -15
WfbLinkManager.javaRecover adapter refresh and permission failures +19/-4

Recover adapter refresh and permission failures

• Skips refresh when USB filter parsing fails and uses a package-scoped permission intent compatible with Android 14. Records adapters as active only after a successful start and displays open failures so later refreshes can retry them.

app/src/main/java/com/openipc/pixelpilot/WfbLinkManager.java

WfbngLink.cppMake native adapter stop idempotent +3/-8

Make native adapter stop idempotent

• Removes the deliberate null dereference when an adapter file descriptor is absent. Already-removed devices now produce a warning and return safely.

app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp

WfbNgLink.javaValidate USB opens and release connection resources +27/-3

Validate USB opens and release connection resources

• Returns start success, handles null connections and invalid descriptors, and safely derives thread names from unexpected device paths. Individual and bulk stops now close UsbDeviceConnection objects and remove retained connection state.

app/wfbngrtl8812/src/main/java/com/openipc/wfbngrtl8812/WfbNgLink.java

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 27, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Early detach wedges worker ✓ Resolved 🐞 Bug ☼ Reliability
Description
If stop() runs after Java starts the worker but before native run() registers the fd, the new
early return sends no stop signal and Java then blocks indefinitely in Thread.join(). The worker
can subsequently finish initialization and enter the blocking RX loop, wedging detach handling or
activity shutdown.
Code

app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[R282-283]

+        __android_log_print(ANDROID_LOG_WARN, TAG, "stop: no rtl device for fd=%d, already gone", fd);
    return;
Evidence
Java records the thread and connection before starting asynchronous native initialization; native
code only registers rtl_devices[fd] after libusb setup, then later enters StartRxLoop, which
returns only after StopRxLoop. The detach path calls native stop and unconditionally joins, so
returning while registration is still pending loses the only shutdown request and leaves that join
blocked.

app/wfbngrtl8812/src/main/java/com/openipc/wfbngrtl8812/WfbNgLink.java[111-115]
app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[86-128]
app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[241-243]
app/wfbngrtl8812/src/main/java/com/openipc/wfbngrtl8812/WfbNgLink.java[144-157]
app/src/main/java/com/openipc/pixelpilot/WfbLinkManager.java[151-159]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A stop request arriving before native fd registration is discarded, after which Java can wait forever for the worker that proceeds into its blocking RX loop.
## Issue Context
`WfbNgLink.start()` publishes and starts the Java thread asynchronously. Native setup does not insert the fd into `rtl_devices` until later, while Java `stop()` immediately calls native stop and then joins without a timeout.
## Fix Focus Areas
- app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[86-128]
- app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[278-283]
- app/wfbngrtl8812/src/main/java/com/openipc/wfbngrtl8812/WfbNgLink.java[111-115]
- app/wfbngrtl8812/src/main/java/com/openipc/wfbngrtl8812/WfbNgLink.java[149-157]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can turn on the rule miner and Qodo learns your standards from review history

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp
iflyhere added a commit to iflyhere/PixelPilot that referenced this pull request Sep 2, 2026
"No compatible wifi adapter found." was shown whenever no adapter ended up
running, which covers three unrelated problems: nothing plugged in, a dongle
whose id is not in usb_device_filter.xml, and a dongle that was found but failed
to start. Only the middle one is what the message claims, and the last one
became reachable when OpenIPC#116 stopped recording an adapter as active unless it
actually came up.

- getAttachedAdapters() logs every attached device with VID:PID, manufacturer
  and product name, and whether the filter matched. That id is exactly what a
  bug report needs in order to add an adapter, and there is no other way to read
  it - sysfs is not accessible to the shell on Horizon OS and dumpsys usb does
  not print host devices there.
- the message now distinguishes "none attached", "found but could not be
  started - see the log" and "waiting for permission".

Also: joinBounded() now releases the device claim even when the driver thread
outstays the timeout. Holding it would leave the adapter unopenable until the
process restarts, and the user would be told there is no compatible adapter -
a worse failure than the duplicate RX loop it guards against, which the
per-instance check and the single-owner handoff already cover.
@vertexodessa

Copy link
Copy Markdown
Collaborator

@iflyhere thank you for the PR! I checked Qodo's comment, it was correct, and the bounded join fixes the ANR half of it. The other half is still there though, see below. could you please fix and I'll merge the PR

  1. The lost-stop window is wider than "fd not yet in rtl_devices". It runs all the way to StartRxLoop: devourer clears should_stop on entry (src/jaguar1/RtlJaguarDevice.cpp:1299), so a StopRxLoop that lands anywhere during the chip bring-up in InitWrite is thrown away too, and that is the longest part of run(). When that happens joinBounded gives up after 3 s, stop() drops the entry from linkThreads and linkConns and calls conn.close(). libusb_wrap_sys_device doesn't dup the fd (it stores it with fd_keep, the "holds a dup" comment in the PR isn't right), so that closes the fd libusb is polling. The kernel cancels the URBs on close but libusb never reaps them: op_handle_events only handles POLLERR, POLLNVAL isn't checked anywhere, so poll() returns immediately and the devourer loop spins on one core waiting for active > 0 to drop, which it never does. And since the map entries are gone, the new "already running" check in start() can't see it; the next openDevice will most likely get the same fd number back, and its run() then overwrites rtl_devices[fd] under the spinning thread. rl8822bu support #105 has the right shape for this: a stop_requested_fds set that run() checks after CreateRtlDevice and again right before StartRxLoop. It narrows the gap to a few instructions rather than closing it (closing it needs devourer to stop clearing the flag), but that's good enough for now. Both PRs touch WfbngLink::stop, so one of them will need a rebase either way.

  2. Minor, not blocking: 3 s of join on the main thread in onPause is under the ANR limit but still a visible freeze. Moving the stop off the main thread would be a good follow-up.

@vertexodessa vertexodessa mentioned this pull request Sep 2, 2026
Four separate ways the adapter path can take the app down or wedge it. All of
them are easy to hit on a powered hub that re-enumerates the dongle, which is
how a lot of ground stations are wired.

1. Deliberate null deref. WfbngLink::stop() ran a CRASH() macro
   (`int *i = 0; *i = 42;`) when the fd was no longer in rtl_devices. That is
   a recoverable state - the adapter was already gone - and it killed the
   process. Removed, now a warning and return.

2. NPE on openDevice(). UsbManager.openDevice() returns null when the
   permission was revoked or the device disappeared between the permission
   check and the open; getFileDescriptor() was called on it unconditionally.
   start() now returns false instead, WfbLinkManager reports it and leaves the
   adapter out of activeWifiAdapters so the next refresh retries it. Before,
   a failed adapter was recorded as active and never retried.

3. Leaked usbfs descriptors. UsbDeviceConnection was never closed and
   linkConns was never cleared, so every attach/detach cycle leaked one fd
   plus the map entry.

4. USB permission dialog on Android 14. requestPermission() got a
   PendingIntent built from an implicit Intent. Android 14 refuses to deliver
   those to a runtime registered receiver, so the result never arrived and the
   app sat on "No permission for wifi adapter(s)". setPackage() added.

Also: refreshAdapters() dereferenced getAttachedAdapters() without checking
for the null it returns when the device filter fails to parse, and the wfb
thread name indexed split()[1] without checking the device name matched
/dev/bus/usb/.
Found on a Quest 3 while the app was unresponsive: the main thread was asleep
inside stopAll()'s t.join() and Android killed the window with

  Input dispatching timed out ... Waited 5000ms for MotionEvent
  ANR in com.openipc.pixelpilot (com.openipc.pixelpilot/.VideoActivity)

stopAdapters() is called from onPause(), onStop() and the channel/bandwidth
menus, so this join runs on the main thread. StopRxLoop() only breaks the
receive loop; the thread then still has to stop the TX frame and the adaptive
link, power the chip down, release the USB interface and exit libusb. If any of
that does not come back, the UI is frozen until the watchdog fires.

The join is now bounded at 3000 ms - about what a healthy unwind needs - and
logs when a thread outstays it instead of hanging the UI.

Also: start() refuses a device that already has a live thread. linkThreads.put()
overwrites the entry, so an older thread would be orphaned, never joined, and
its interface never released.
Recording an adapter as active only when it actually came up means an empty
activeWifiAdapters now covers two different problems: nothing compatible is
attached, or something compatible is attached and could not be opened. Showing
"No compatible wifi adapter found." for both sends people looking for a
usb_device_filter.xml entry that is already there.
@iflyhere
iflyhere force-pushed the fix/usb-adapter-lifecycle branch from f0599eb to 901c0e8 Compare September 2, 2026 20:45
The bounded join fixed the ANR but not the reason the join was timing out in the first place,
as pointed out in review.

StopRxLoop() only sets a flag, and RtlJaguarDevice::StartRxLoop() clears it on entry. So a
stop is thrown away anywhere between the fd being handed to run() and the loop actually
starting - which includes the whole chip bring-up in InitWrite(), the longest part of run().
Until CreateRtlDevice() there is not even an entry in rtl_devices for stop() to find, so it
returns "already gone" and does nothing at all. run() then blocks in a loop nobody asked for.

stop() now records the fd in stop_requested_fds before anything else, and run() checks it at
the two points where the flag itself cannot be trusted: after CreateRtlDevice(), and again
immediately before entering the loop. Skipping the loop falls through to the same teardown a
StopRxLoop() would have taken. run() clears the entry on the way in, because fd numbers are
reused and a stale request must not abort a new session. This narrows the window to a few
instructions rather than closing it - closing it needs devourer to stop clearing the flag.

The second half was the timeout path itself. libusb_wrap_sys_device() keeps the fd it is given
rather than duplicating it - the comment claiming otherwise was wrong - so closing the
UsbDeviceConnection after a timed-out join pulled the fd out from under a libusb that was
still polling it. The kernel cancels the URBs on close, but libusb never reaps them, because
op_handle_events() checks POLLERR and not POLLNVAL: poll() then returns immediately forever
and the loop spins on one core waiting for a transfer count that never drops. Dropping the map
entries at the same time hid it from the duplicate check in start(), so the next openDevice()
would most likely be handed the same fd number back and overwrite rtl_devices[fd] underneath
the spinning thread.

So a timed-out join now leaves both the thread and its connection in place. start() refuses a
second RX loop on that device, and releases the connection once the old thread has actually
finished.

Still worth a follow-up: 3s of join on the main thread from onPause is under the ANR limit but
visible. Moving the stop off the main thread would remove it.

OpenIPC#105 touches WfbngLink::stop too, so whichever lands second will need a rebase.
@iflyhere

iflyhere commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

You're right on both counts, and the fd_keep detail is the part I had backwards. Fixed and
pushed, rebased onto master.

The lost-stop window. stop() now records the fd in a stop_requested_fds set before
anything else — including before the rtl_devices lookup, since until CreateRtlDevice()
there is no entry to find and the old code returned "already gone" and did nothing at all.
run() clears the entry on the way in (fd numbers are reused, so a stale request must not
abort a new session) and checks it twice: after CreateRtlDevice(), and immediately before
entering the loop. Skipping the loop falls through to the same teardown a StopRxLoop() would
have taken.

I confirmed the devourer side on the pinned submodule — StartRxLoop() opens with

  /* Restartable: clear any stop request left by a prior StopRxLoop(). */
  should_stop = false;

so as you say this narrows the window to a few instructions rather than closing it. Closing it
needs devourer to stop clearing the flag, or to take the stop request as an argument.

The fd close. Corrected — libusb_wrap_sys_device() keeps the fd rather than duplicating
it, so the "holds a dup" comment was wrong and closing the connection after a timed-out join
was actively harmful. A timed-out join now leaves both the thread and its UsbDeviceConnection
in place; start() refuses a second RX loop on that device and releases the connection once
the old thread has actually finished, so the fd is still reclaimed, just not while libusb is
polling it. That also keeps the duplicate check able to see the thread, which was the other
half — otherwise the next openDevice() gets the same fd number and overwrites
rtl_devices[fd] underneath it.

The reason to keep the fd open rather than close it is exactly the POLLNVAL gap you
described: the kernel cancels the URBs but op_handle_events() never reaps them, so poll()
returns immediately forever and the thread spins on a core. Leaving the fd valid means the
thread either finishes properly or at worst stays blocked instead of spinning.

On the follow-up: agreed that 3 s of join on the main thread is still visible even though
it is under the ANR limit. I left it out of this PR to keep it reviewable — happy to do the
off-main-thread stop as a separate one once this and #105 have landed.

On #105: noted, and I'd suggest #105 goes first. It has the wider change to stop(), and
rebasing this on top of it is a smaller job than the other way round. Say the word if you'd
rather I rebase onto it now rather than onto master.

Compile tested for arm64-v8a + armeabi-v7a.

@vertexodessa
vertexodessa merged commit d613fc1 into OpenIPC:master Sep 2, 2026
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.

2 participants