Skip to content

Fix USB CAT write/read NPE when port I/O races an in-progress (re)open#651

Merged
patrickrb merged 2 commits into
devfrom
optio/task-57266934-433d-457c-86b1-5197e1387164
Jul 23, 2026
Merged

Fix USB CAT write/read NPE when port I/O races an in-progress (re)open#651
patrickrb merged 2 commits into
devfrom
optio/task-57266934-433d-457c-86b1-5197e1387164

Conversation

@patrickrb

Copy link
Copy Markdown
Owner

Root cause

CommonUsbSerialPort.open() publishes mConnection before openInt() assigns mReadEndpoint/mWriteEndpoint:

mConnection = connection;          // (1) connection is now visible
try {
    openInt(connection);           // (2) endpoints assigned here
    ...

openInt() and the catch-path close() both need the connection during setup, so the ordering is deliberate — but it leaves a window where mConnection != null while mWriteEndpoint/mReadEndpoint are still null.

read()/write() only guarded mConnection == null. A call landing in that window passed the guard and then dereferenced a null endpoint:

if (mWriteBuffer == null) {
    mWriteBuffer = new byte[mWriteEndpoint.getMaxPacketSize()]; // NPE
}

CableSerialPort.sendData() catches IOException but not the RuntimeException NPE, so it escaped to the TX worker thread and crashed the app.

Evidence

Sentry fatal (Pixel 8 / Android 16, radio.ks3ckc.ft8af@1.1.0-dev+5):

NullPointerException: 'int android.hardware.usb.UsbEndpoint.getMaxPacketSize()' on a null object reference
  at CommonUsbSerialPort.write(CommonUsbSerialPort.java:233)
  at CableSerialPort.writeIfOpen / sendData
  at CableConnector.setPttOn
  at Yaesu39Rig.setPTT
  at MainViewModel$6.beginKeying / onBeforeTransmit
  at FT8TransmitSignal$DoTransmitRunnable.run   (ThreadPoolExecutor worker)

The window is hit in practice during CAT auto-reconnect: CableSerialPort.prepare() allocates a fresh port (driver.getPorts().get(portNum), endpoints null) and connect() calls open(); a TX PTT firing from the transmit thread during that open() keys the crash. This is the endpoint-side twin of the earlier disconnect-race guards (writeIfOpen, PR #647).

Fix

Guard the endpoint as well as the connection, via a small unit-testable helper applied symmetrically to read() and write():

static boolean ioEndpointReady(Object connection, Object endpoint) {
    return connection != null && endpoint != null;
}

A missing connection or endpoint now throws the same recoverable IOException("Connection closed") that the CAT/TX layer already handles, instead of crashing. This is a strict superset of the previous mConnection == null check — no behavior change once the port is fully open.

Testing

  • New CommonUsbSerialPortEndpointReadyTest (pure JVM) covers the readiness decision, including the exact crash case ioEndpointReady(connection, null) == false.
  • ./gradlew :app:testDebugUnitTest — full suite green.

Risk

Very low. Localized to two guard checks in CommonUsbSerialPort; only adds an earlier, already-handled IOException on a previously-crashing path. No protocol, DSP, or performance impact.

🤖 Generated with Claude Code

CommonUsbSerialPort.open() publishes mConnection *before* openInt() assigns
mReadEndpoint/mWriteEndpoint (openInt and the catch-path close() both need the
connection during setup). This leaves a window where mConnection != null but the
endpoints are still null. read()/write() only guarded `mConnection == null`, so
a call landing in that window passed the check and then NPE'd dereferencing the
endpoint — write() at `mWriteEndpoint.getMaxPacketSize()`.

sendData() catches IOException but not the RuntimeException NPE, so it escaped to
the TX worker thread and crashed the app. The window is hit in practice during
CAT auto-reconnect: prepare() allocates a fresh port (endpoints null) and a TX
PTT firing from the transmit thread while open() is running keys the crash
(observed on a Pixel 8 / Android 16: Yaesu39 setPTT -> FT8TransmitSignal ->
CommonUsbSerialPort.write).

Guard the endpoint as well as the connection via a small, unit-testable
ioEndpointReady() helper, applied symmetrically to read() and write(). A missing
connection OR endpoint now throws the same recoverable IOException("Connection
closed") the CAT/TX layer already handles instead of crashing. Strict superset of
the previous check — no behavior change once the port is fully open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 36.81%. Comparing base (ac169cc) to head (77c776c).
⚠️ Report is 6 commits behind head on dev.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff              @@
##                dev     #651      +/-   ##
============================================
+ Coverage     36.73%   36.81%   +0.08%     
- Complexity      197      207      +10     
============================================
  Files           216      218       +2     
  Lines         26888    27012     +124     
  Branches       3287     3315      +28     
============================================
+ Hits           9877     9945      +68     
- Misses        16785    16832      +47     
- Partials        226      235       +9     
Flag Coverage Δ
android 15.35% <ø> (+0.31%) ⬆️
native 9.93% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.
see 8 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the USB-serial CAT transport against a real-world race during (re)open where mConnection becomes visible before openInt() assigns mReadEndpoint/mWriteEndpoint, preventing an uncaught NullPointerException from escaping CommonUsbSerialPort.read()/write() and crashing the TX/CAT worker thread.

Changes:

  • Add CommonUsbSerialPort.ioEndpointReady(connection, endpoint) to centralize the “safe to do I/O” decision and make it unit-testable.
  • Update CommonUsbSerialPort.read() and write() to treat a missing endpoint the same as a missing connection (throw recoverable IOException("Connection closed")).
  • Add a pure-JVM unit test covering endpoint readiness cases, including the exact crash window.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
ft8af/app/src/main/java/com/k1af/ft8af/serialport/CommonUsbSerialPort.java Adds an endpoint-readiness helper and extends read/write guards to cover the connection-published/endpoint-null race window.
ft8af/app/src/test/java/com/k1af/ft8af/serialport/CommonUsbSerialPortEndpointReadyTest.java Adds JVM unit coverage for the readiness decision to prevent regressions of the crash window.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ft8af/app/src/main/java/com/k1af/ft8af/serialport/CommonUsbSerialPort.java Outdated
A concurrent close() (cable yanked on another thread) nulls the mConnection /
mReadEndpoint / mWriteEndpoint fields, so an in-flight read()/write() that
dereferenced the live fields could still NPE after the readiness guard passed.
Read them into locals once, up front, and use the locals throughout — a
concurrent close now degrades to the existing recoverable IOException path
instead of crashing the IO thread. The guard still covers the (re)open race
the PR targets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@patrickrb
patrickrb merged commit 389cf54 into dev Jul 23, 2026
17 checks passed
@patrickrb
patrickrb deleted the optio/task-57266934-433d-457c-86b1-5197e1387164 branch July 23, 2026 21:16
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