Fix cross-thread crash on the shared transmitMessages calling list#579
Fix cross-thread crash on the shared transmitMessages calling list#579patrickrb wants to merge 1 commit into
Conversation
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 Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ 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
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
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.transmitMessagesto aCopyOnWriteArrayList-backedListand widen helpers/consumers fromArrayListtoList. - Snapshot
transmitMessagesonce inFT8TransmitSignal.doComplete()before reverse index scans to avoidsize()/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) { |
Root cause
GeneralVariables.transmitMessages— the calling-UI "followed entries" list — was a plainArrayListmutated concurrently with no external lock from three threads:MainViewModel.findIncludedCallsignsappends matches then trims the front viadeleteArrayListMore(remove(0));clearTransmittingMessageclears it every cycleFT8TransmitSignal.doCompletereverse-scans it (size()-1 .. 0,get(i)) for the QSO signal reports;onBeforeTransmitappendsGridTrackerMainActivity/GridMarkerInfoWindowappendArrayListis not thread-safe. Under that contention the backing array is corrupted, and the TX thread's reversesize()-then-get(i)scan throwsIndexOutOfBoundsExceptionwhen a concurrentremove(0)/clearshrinks the list between the two calls. That scan runs indoCompleteat 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
ft8Messageslist is already lock-guarded;transmitMessageswas 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
transmitMessagesaCopyOnWriteArrayListso everyadd/remove/clearis atomic.deleteArrayListMoreand theCallingListAdapterctor/field are widened fromArrayListtoList<Ft8Message>(source-compatible;ft8Messagesis untouched and still anArrayListguarded bysynchronized(ft8Messages)).FT8TransmitSignal.doCompletebefore the two reverse index scans, sosize()/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: wasArrayList).reverseScanSnapshot_survivesConcurrentDecodeMutation— the production snapshot reverse scan survives 20 000 iterations of concurrent decode-threadadd/deleteArrayListMore/clearon the shared field with no throwable (fails pre-fix: copying the liveArrayListunder mutation throws).directSizeThenGet_onShrunkList_throws_butSnapshotIsImmune— deterministic proof that a livesize()/get()throwsIndexOutOfBoundswhen 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) — greenRisk
Low. Localised change; no protocol/DSP/interop behaviour touched.
transmitMessagesholds only the small "calling list" of followed stations, soCopyOnWriteArrayList's copy-on-mutation cost is negligible (bounded well under theMESSAGE_COUNTcap and off the UI thread).