Describe the bug
While hosting a Constructed network game with 4 human players (3 remote), a remote player attempted to cast a spell they could not pay for. The resulting reveal/rollback (PlayerControllerHuman.reveal → RemoteClientGuiGame.hideZones → syncAndSend) ran on the EDT (the AWT Event Dispatch Thread) concurrently with game-state mutation, and the host's EDT died with java.util.ConcurrentModificationException in the netplay delta-sync path. The netty server threads survived the EDT death, leaving the three remote clients connected to a headless host — the game continues to appear "up" but the host can never act again.
Per the stack trace, the delayed-props list in forge.trackable.Tracker is being read by the sync path while the game thread mutates it, with no synchronization. Observed once in ~3 hours of 4-player play; likelihood presumably scales with sync traffic (player count / board size).
To Reproduce
The in-game race is not deterministic, but a minimal two-thread program against Tracker reproduces the identical exception (same top stack frames) within seconds, every run:
- On one thread, queue and clear delayed prop changes on a frozen
Tracker (as the game thread does during play).
- Concurrently on another thread, repeatedly call
getDelayedPropsFor (as the delta-sync path does).
ConcurrentModificationException at Tracker.getDelayedPropsFor, typically within a few seconds.
Expected behavior
The host UI should not crash, and a sync failure should not leave a headless host with the server still serving remote clients.
Screenshots
N/A — stack trace below.
EDT > java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1013)
at java.base/java.util.ArrayList$Itr.next(ArrayList.java:967)
at forge.trackable.Tracker.getDelayedPropsFor(Tracker.java:98)
at forge.gamemodes.net.server.DeltaSyncManager.mergeDelayedProps(DeltaSyncManager.java:311)
at forge.gamemodes.net.server.DeltaSyncManager.buildPropertyMap(DeltaSyncManager.java:284)
at forge.gamemodes.net.server.DeltaSyncManager.collectObjectDelta(DeltaSyncManager.java:231)
at forge.gamemodes.net.server.DeltaSyncManager.walkAndCollect(DeltaSyncManager.java:196)
at forge.gamemodes.net.server.DeltaSyncManager.walkAndCollect(DeltaSyncManager.java:209)
at forge.gamemodes.net.server.DeltaSyncManager.walkAndCollect(DeltaSyncManager.java:212)
at forge.gamemodes.net.server.DeltaSyncManager.walkAndCollect(DeltaSyncManager.java:212)
at forge.gamemodes.net.server.DeltaSyncManager.collectDeltas(DeltaSyncManager.java:116)
at forge.gamemodes.net.server.RemoteClientGuiGame.updateGameView(RemoteClientGuiGame.java:236)
at forge.gamemodes.net.server.RemoteClientGuiGame.updateGameView(RemoteClientGuiGame.java:195)
at forge.gamemodes.net.server.RemoteClientGuiGame.syncAndSend(RemoteClientGuiGame.java:155)
at forge.gamemodes.net.server.RemoteClientGuiGame.hideZones(RemoteClientGuiGame.java:392)
at forge.player.PlayerControllerHuman.lambda$reveal$2(PlayerControllerHuman.java:985)
at java.desktop/java.awt.event.InvocationEvent.dispatch(InvocationEvent.java:318)
at java.desktop/java.awt.EventQueue.dispatchEventImpl(EventQueue.java:773)
at java.desktop/java.awt.EventQueue$4.run(EventQueue.java:720)
at java.desktop/java.awt.EventQueue$4.run(EventQueue.java:714)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:399)
at java.base/java.security.ProtectionDomain$JavaSecurityAccessImpl.doIntersectionPrivilege(ProtectionDomain.java:86)
at java.desktop/java.awt.EventQueue.dispatchEvent(EventQueue.java:742)
at java.desktop/java.awt.EventDispatchThread.pumpOneEventForFilters(EventDispatchThread.java:203)
at java.desktop/java.awt.EventDispatchThread.pumpEventsForFilter(EventDispatchThread.java:124)
at java.desktop/java.awt.EventDispatchThread.pumpEventsForHierarchy(EventDispatchThread.java:113)
at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:109)
at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:101)
at java.desktop/java.awt.EventDispatchThread.run(EventDispatchThread.java:90)
Desktop (please complete the following information):
- OS: macOS 26.5.2 (Apple Silicon)
- Java: Temurin 17.0.19
- Version: Forge 2.0.13 (official release). The code in the stack trace is unchanged on current master, so this likely affects snapshots as well.
Additional context
UPnP disabled, manual port-forward, default port 36743. Full host session log preserved and available on request. Notably, the raw log shows concurrent DeltaSync INFO lines (including a GameEventSpellAbilityCast batch) interleaved between the stack frames of the exception printout — two threads were writing simultaneously at the moment of the crash, consistent with the race. After the crash, jstack showed no AWT-EventQueue thread (EDT dead) while netty threads and 3 ESTABLISHED client sockets remained.
Describe the bug
While hosting a Constructed network game with 4 human players (3 remote), a remote player attempted to cast a spell they could not pay for. The resulting reveal/rollback (
PlayerControllerHuman.reveal→RemoteClientGuiGame.hideZones→syncAndSend) ran on the EDT (the AWT Event Dispatch Thread) concurrently with game-state mutation, and the host's EDT died withjava.util.ConcurrentModificationExceptionin the netplay delta-sync path. The netty server threads survived the EDT death, leaving the three remote clients connected to a headless host — the game continues to appear "up" but the host can never act again.Per the stack trace, the delayed-props list in
forge.trackable.Trackeris being read by the sync path while the game thread mutates it, with no synchronization. Observed once in ~3 hours of 4-player play; likelihood presumably scales with sync traffic (player count / board size).To Reproduce
The in-game race is not deterministic, but a minimal two-thread program against
Trackerreproduces the identical exception (same top stack frames) within seconds, every run:Tracker(as the game thread does during play).getDelayedPropsFor(as the delta-sync path does).ConcurrentModificationExceptionatTracker.getDelayedPropsFor, typically within a few seconds.Expected behavior
The host UI should not crash, and a sync failure should not leave a headless host with the server still serving remote clients.
Screenshots
N/A — stack trace below.
Desktop (please complete the following information):
Additional context
UPnP disabled, manual port-forward, default port 36743. Full host session log preserved and available on request. Notably, the raw log shows concurrent
DeltaSyncINFO lines (including aGameEventSpellAbilityCastbatch) interleaved between the stack frames of the exception printout — two threads were writing simultaneously at the moment of the crash, consistent with the race. After the crash,jstackshowed no AWT-EventQueue thread (EDT dead) while netty threads and 3 ESTABLISHED client sockets remained.