fix(commons): guard RelayLatencyTracker.sweep against ConcurrentModificationException

The per-relay pending maps in RelayLatencyTracker are
Collections.synchronizedMap(LinkedHashMap): individual read/write
ops are thread-safe, but per the synchronizedMap javadoc iteration
is NOT — callers MUST hold the map's monitor while walking its
views. sweep() was iterating directly, so any network-dispatcher
mutation (adding a pending REQ, receiving an OK) during a sweep
would throw ConcurrentModificationException on AWT-EventQueue-0,
killing the Compose renderer while coroutine work kept running.

Pre-existing bug, documented in memory
desktop_relay_health_cme_crash. Ordinarily "not our problem", but
it's actively blocking manual T3 testing of this branch's AUTH
approval banner: adding any new relay triggers a
RelayHealthStore.reclassify sweep, so testers can't get a banner
render in without hitting the crash. Fix it here so the branch is
actually testable end-to-end.

Wrap both iteration loops in synchronized(pending) blocks. Sweep is
O(pending) with typically single-digit entries per relay, so the
hold time is negligible and the network dispatcher just briefly
waits.

Reproduced during manual T3 testing 2026-07-06 when adding
wss://pyramid.fiatjaf.com. Stack: RelayLatencyTracker.sweep:182 →
RelayHealthStore$reclassify$flagged$1.invokeSuspend:268.
This commit is contained in:
nrobi144
2026-07-09 07:33:43 +03:00
parent 8c844d76db
commit 3f0ad05b58
@@ -164,6 +164,18 @@ class RelayLatencyTracker(
/**
* Expires pending entries older than the configured TTLs and records the TTL value as the
* sample (per the brainstorm: "punish silent relays"). Idempotent and cheap.
*
* The per-relay pending maps are `Collections.synchronizedMap(LinkedHashMap)` — their
* individual reads and writes are thread-safe, but iteration is NOT: per
* `Collections.synchronizedMap` javadoc, the caller MUST hold the returned map's
* monitor while iterating. Directly iterating triggers a
* `ConcurrentModificationException` when a producer thread (network dispatcher)
* mutates the map while the sweep is walking it — reliably reproduced on macOS
* during any relay-add on Amethyst Desktop as of 2026-07-06.
*
* Fix: iterate under `synchronized(pending)` blocks so the network dispatcher
* waits until sweep releases the monitor. The sweep is O(pending), typically
* ~single-digit entries per relay, so the hold time is negligible.
*/
override fun sweep(nowMs: Long) {
// Per-relay pending maps are `Collections.synchronizedMap(LinkedHashMap)` — the