Skip to content

Fix cross-thread crash on the shared transmitMessages calling list#579

Open
patrickrb wants to merge 1 commit into
devfrom
optio/task-21b6a102-6ed5-4d2b-a247-d3afe51ed570
Open

Fix cross-thread crash on the shared transmitMessages calling list#579
patrickrb wants to merge 1 commit into
devfrom
optio/task-21b6a102-6ed5-4d2b-a247-d3afe51ed570

Conversation

@patrickrb

Copy link
Copy Markdown
Owner

Root cause

GeneralVariables.transmitMessages — the calling-UI "followed entries" list — was a plain ArrayList mutated concurrently with no external lock from three threads:

Thread Access
decode MainViewModel.findIncludedCallsigns appends matches then trims the front via deleteArrayListMore (remove(0)); clearTransmittingMessage clears it every cycle
TX-sequencer FT8TransmitSignal.doComplete reverse-scans it (size()-1 .. 0, get(i)) for the QSO signal reports; onBeforeTransmit appends
UI GridTrackerMainActivity / GridMarkerInfoWindow append

ArrayList is not thread-safe. Under that contention the backing array is corrupted, and the TX thread's reverse size()-then-get(i) scan throws IndexOutOfBoundsException when a concurrent remove(0)/clear shrinks the list between the two calls. That scan runs in doComplete at every successful QSO completion, with no surrounding try/catch on the TX-sequencer thread → whole-app crash.

This is the highest-severity reachable stability issue remaining in the app (the sibling ft8Messages list is already lock-guarded; transmitMessages was the one shared list left unsynchronised — the live writers on it, unlike the decode-list readers hardened in #567/#574, never took a common lock).

Fix

  • Make transmitMessages a CopyOnWriteArrayList so every add/remove/clear is atomic. deleteArrayListMore and the CallingListAdapter ctor/field are widened from ArrayList to List<Ft8Message> (source-compatible; ft8Messages is untouched and still an ArrayList guarded by synchronized(ft8Messages)).
  • Snapshot the list once in FT8TransmitSignal.doComplete before the two reverse index scans, so size()/get() can't race a concurrent shrink. The private copy preserves the exact most-recent-first (reverse-index) semantics.

The single-threaded happy path is byte-identical.

Testing

New TransmitMessagesConcurrencyTest:

  • transmitMessages_isCopyOnWriteList — the field is a thread-safe list (fails pre-fix: was ArrayList).
  • reverseScanSnapshot_survivesConcurrentDecodeMutation — the production snapshot reverse scan survives 20 000 iterations of concurrent decode-thread add / deleteArrayListMore / clear on the shared field with no throwable (fails pre-fix: copying the live ArrayList under mutation throws).
  • directSizeThenGet_onShrunkList_throws_butSnapshotIsImmune — deterministic proof that a live size()/get() throws IndexOutOfBounds when the list shrinks in the window, while snapshotting first is immune.

Confirmed the first two tests fail against the pre-fix source (stashed the source edits, kept the test) and pass after.

  • :app:testDebugUnitTest — green (full suite)
  • :app:assembleDebug (4 ABIs, native + hamlib) — green

Risk

Low. Localised change; no protocol/DSP/interop behaviour touched. transmitMessages holds only the small "calling list" of followed stations, so CopyOnWriteArrayList's copy-on-mutation cost is negligible (bounded well under the MESSAGE_COUNT cap and off the UI thread).

GeneralVariables.transmitMessages (the calling-UI "followed entries" list)
was a plain ArrayList mutated concurrently with no external lock from three
threads:

  * decode thread   — MainViewModel.findIncludedCallsigns appends matches then
                      trims the front via deleteArrayListMore (remove(0)), and
                      clearTransmittingMessage clears it every cycle;
  * TX-sequencer    — FT8TransmitSignal.doComplete reverse-scans it for the QSO
                      signal reports (size()-1 .. 0, get(i)), onBeforeTransmit
                      appends;
  * UI thread       — GridTrackerMainActivity / GridMarkerInfoWindow append.

Under that contention the ArrayList's backing array is corrupted and the
reverse size()-then-get(i) scan on the TX thread throws IndexOutOfBounds when
a concurrent remove(0)/clear shrinks the list mid-scan — an uncaught crash on
the TX-sequencer thread at QSO completion.

Root cause: an unsynchronised mutable collection shared across threads. Fix:

  * make transmitMessages a CopyOnWriteArrayList so every add/remove/clear is
    atomic (widen deleteArrayListMore + CallingListAdapter to List<Ft8Message>,
    both source-compatible; ft8Messages is untouched);
  * snapshot the list once in FT8TransmitSignal.doComplete before the two
    reverse index scans so size()/get() cannot race a concurrent shrink. The
    private copy preserves the exact most-recent-first semantics.

Happy path (single-threaded) is byte-identical.

Adds TransmitMessagesConcurrencyTest: the field is a CopyOnWriteArrayList, the
snapshot reverse scan survives 20k iterations of concurrent decode-thread
mutation, and a deterministic case showing a live size()/get() throws on a
shrunk list while a snapshot is immune. The first two fail pre-fix.

Verified: :app:testDebugUnitTest and :app:assembleDebug (4 ABIs) both green.

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

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 29.41%. Comparing base (1108045) to head (192bd5c).
⚠️ Report is 1 commits behind head on dev.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff              @@
##                dev     #579      +/-   ##
============================================
+ Coverage     29.38%   29.41%   +0.02%     
  Complexity      197      197              
============================================
  Files           179      179              
  Lines         24076    24097      +21     
  Branches       3265     3268       +3     
============================================
+ Hits           7075     7087      +12     
- Misses        16780    16787       +7     
- Partials        221      223       +2     
Flag Coverage Δ
android 14.62% <ø> (+0.05%) ⬆️
native 9.93% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.
see 4 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 shared “followed entries” calling list (GeneralVariables.transmitMessages) against cross-thread mutation crashes by switching it to a thread-safe list implementation and making TX-side history scans race-free.

Changes:

  • Convert GeneralVariables.transmitMessages to a CopyOnWriteArrayList-backed List and widen helpers/consumers from ArrayList to List.
  • Snapshot transmitMessages once in FT8TransmitSignal.doComplete() before reverse index scans to avoid size()/get(i) races.
  • Add a concurrency-focused unit test to validate the thread-safety and snapshot behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
ft8af/app/src/test/java/com/k1af/ft8af/TransmitMessagesConcurrencyTest.java Adds regression tests covering concurrent mutation and snapshot-based reverse scanning behavior.
ft8af/app/src/main/java/com/k1af/ft8af/ui/CallingListAdapter.java Widens adapter list type from ArrayList to List to accept the updated shared list type.
ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java Switches transmitMessages to a thread-safe list and updates deleteArrayListMore to accept List.
ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java Uses a single snapshot of transmitMessages for reverse scans in doComplete() to prevent cross-thread shrink races.

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

// every add/remove/clear is atomic. Index scans must still snapshot the list
// first (size() then get(i) can otherwise race a concurrent remove) — see
// FT8TransmitSignal.doComplete.
public static List<Ft8Message> transmitMessages = new CopyOnWriteArrayList<>();//List for the calling UI, followed entries
}

public static synchronized void deleteArrayListMore(ArrayList<Ft8Message> list) {
public static synchronized void deleteArrayListMore(List<Ft8Message> list) {
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