UpdateManagerImpl.run() iterates the updaters list without taking the read lock, while register() and unregister() mutate it under the write lock.
UpdateManagerImpl.java:128-131:
private void run() {
pool.schedule(this::run, 5, TimeUnit.SECONDS);
for (Updater u : updaters) {
That's the raw field, unsynchronized. Every other access is guarded — updaters() takes the read lock (:60), register() (:78) and unregister() (:99) take the write lock. updaters is a plain ArrayList, so a concurrent register() can throw ConcurrentModificationException in the scheduler thread or expose a partially-updated list.
The exposure is wider than startup registration, though. run() schedules its own next invocation at :129 before doing any work, and the pool is Executors.newScheduledThreadPool(Math.max(4, cores/2)) (FetcharrAPIImpl.java:22). Since u.run() blocks on HTTP to each *arr, any cycle taking longer than 5 seconds overlaps the next — so concurrent run() bodies are the normal case on a real install, not a rare race.
That matters for the fix: swapping in the existing updaters() snapshot closes the CME but not the overlap. If overlapping cycles aren't intended, :129 is the thing to look at.
Read from source; not run.
Found with Claude Code.
UpdateManagerImpl.run()iterates theupdaterslist without taking the read lock, whileregister()andunregister()mutate it under the write lock.UpdateManagerImpl.java:128-131:That's the raw field, unsynchronized. Every other access is guarded —
updaters()takes the read lock (:60),register()(:78) andunregister()(:99) take the write lock.updatersis a plainArrayList, so a concurrentregister()can throwConcurrentModificationExceptionin the scheduler thread or expose a partially-updated list.The exposure is wider than startup registration, though.
run()schedules its own next invocation at:129before doing any work, and the pool isExecutors.newScheduledThreadPool(Math.max(4, cores/2))(FetcharrAPIImpl.java:22). Sinceu.run()blocks on HTTP to each *arr, any cycle taking longer than 5 seconds overlaps the next — so concurrentrun()bodies are the normal case on a real install, not a rare race.That matters for the fix: swapping in the existing
updaters()snapshot closes the CME but not the overlap. If overlapping cycles aren't intended,:129is the thing to look at.Read from source; not run.
Found with Claude Code.