Enforce single-thread engine access - #11718
Conversation
Reported from a four-player Commander game over the network: two crash dialogs
in one session, and after the second nobody could pass priority or respond to
the stack for the rest of the game.
java.util.ConcurrentModificationException
at forge.trackable.Tracker.getDelayedPropsFor(Tracker.java:98)
at forge.gamemodes.net.server.DeltaSyncManager.collectDeltas(DeltaSyncManager.java:116)
at forge.gamemodes.net.server.RemoteClientGuiGame.handleGameEvents(RemoteClientGuiGame.java:579)
Both crashes are the same collision; what differs is the thread that was
walking. The first unwound a card-tap handler, so only that action died. The
second unwound PhaseHandler.mainGameLoop with nothing to catch it, so the game
loop thread ended and priority never came back.
The collision itself: collectDeltas reads the tracker's queue of delayed
property changes while another thread clears it. The host log shows the other
side — a client's sendYieldUpdate is dispatched to a background thread and
reaches a speculative freeze bracket that ends in GameActionUtil:287 calling
clearDelayed(). sendYieldUpdate is the one client message that is not a reply
to a prompt, so it is the one that can arrive mid-walk.
Retry the walk rather than letting the exception reach the caller. Deltas
already collected are kept, since the dirty flags behind them have been cleared
and no later walk would find them again, and the visited set is rebuilt per
attempt so the walk does not stop at the root. If no attempt completes the
packet ships with what it has, and both the checksum and the registration
pruning are skipped, because neither is meaningful over a partial walk.
This keeps the match playable when the collision happens; it does not stop the
collision.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When the AI's evaluation runs over its time limit the caller cancels it, waits 500ms, and then carries on regardless. The cancel flag is only checked between abilities, so a thread part-way through a long one does not see it in time and keeps going, changing the same game the caller has already moved on in. Thread.stop() used to catch that, and it is gone on Android and removed in newer Java, so there the overlap is the only outcome. Wait for the ability already running to finish instead. The time limit still bounds how long the AI thinks and its answer is still thrown away; only the overlap goes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing records which thread is allowed to run engine code. ThreadUtil .isGameThread() stands in for it by checking whether the thread's name starts with "Game", and that is wrong for the 65 call sites which reach invokeInBackgroundThread for unrelated work — a deck chooser or a download thread passes the test, so GameAction.invoke runs engine code on it. What keeps threads apart today is that they usually happen to be waiting on an input latch, which is why two of them walking the view graph at once is rare rather than impossible. Give each game an owner instead. A thread takes ownership when it starts running engine code and gives it up whenever it blocks, so a thread waiting on a player, a client or a timer holds nothing. A thread that wakes up takes ownership again before continuing, which also closes the gap left by InputSyncronizedBase.stop() releasing its latch part-way through unwinding. Threads that must not be made to wait on the engine hand their work to a per-game worker instead, so the event dispatch thread and the protocol threads no longer walk the graph themselves. The caller waits for the worker, so everything still happens in the same order as before. Measured on a four-player game, the dispatch thread waits 2.7ms on average and 9.8ms at worst, and 94% of engine work never leaves the thread it started on. A thread that still cannot get in after three seconds runs anyway and says so. That can only happen if some blocking call is missing its release, and running without the guarantee is what the code did before this change, where freezing the game is not. Changes made by a thread that never took ownership are reported for the same reason: the lock cannot see them, and they are what a missed entry point looks like. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks Commit # 1:Looks like that's what I was after for the time being + sounds like it wourld work on its own so that should be spun-off first # 2:I don't see how increasing the extra grace wait time there improves things in a reliable way. If the timeout is reached most likely AI thread has crashed or is stuck in an (almost) infinite loop already. Tbh that whole timeout mess was introduced by another dev against my concerns - it eventually needs a way better implementation anyway. :/ # 3:This will leave us with one rather complex threading change, something I'll probably only have time for during my next holiday. |
Take master's ConcurrentModificationException catch in collectDeltas, which landed via the spin-off of this branch's first commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tool4ever this needs some more testing but might be a solution to playing whack-a-mole with network CMEs.