Skip to content

ConcurrentModificationException in netplay delta sync kills host UI thread; remote clients left connected to headless host #11535

Description

@dschartman

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.revealRemoteClientGuiGame.hideZonessyncAndSend) 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:

  1. On one thread, queue and clear delayed prop changes on a frozen Tracker (as the game thread does during play).
  2. Concurrently on another thread, repeatedly call getDelayedPropsFor (as the delta-sync path does).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions