Skip to content

Commit 195b5aa

Browse files
committed
fix: resolve 3 concurrency issues in multi-table bot matches on Folia
- MahjongTableSession: make botTask volatile and synchronize setBotTask/clearBotTaskIfSame/cancelBotTask to prevent orphaned tasks when global-timer and region threads race on setBotTask (caused duplicate bot actions in multi-table scenarios). - MahjongTableManager: move bot watchdog inside runRegion callback so game state (engine, currentPlayer, pendingReaction) is read from the owning region thread, not from the global timer thread. Also add try-catch around session.tick() which was previously swallowing dispatch failures. - GameRoomManager: schedule forceEndTable on the table's region thread instead of calling it directly from tick() (global timer), preventing cross-thread mutation of round controller and viewer presentation state when player exit countdowns expire. Fixes: bot matches freezing or duplicating actions when multiple game rooms have tables running simultaneously.
1 parent 11d44e0 commit 195b5aa

3 files changed

Lines changed: 39 additions & 19 deletions

File tree

src/main/java/top/ellan/mahjong/gameroom/GameRoomManager.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,13 @@ public void tick() {
338338
String tableId = this.exitCountdownTableIds.get(playerId);
339339
if (tableId != null) {
340340
this.logDebug("Player " + playerId + " countdown expired, force-ending table " + tableId);
341-
this.tableManager.forceEndTable(tableId);
341+
// Schedule force-end on the table's region thread to avoid
342+
// cross-thread mutation of game state (round controller,
343+
// viewer presentation, bot task, etc.) on Folia.
344+
MahjongTableSession session = this.tableManager.resolveTableById(tableId);
345+
if (session != null) {
346+
this.scheduler.runRegion(session.center(), () -> this.tableManager.forceEndTable(tableId));
347+
}
342348
}
343349
iterator.remove();
344350
this.removeCountdownState(playerId);

src/main/java/top/ellan/mahjong/table/core/MahjongTableManager.java

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -735,27 +735,41 @@ private void dispatchTableTicks() {
735735
// other table on the server.
736736
for (MahjongTableSession session : this.directory.tables()) {
737737
try {
738-
this.plugin.scheduler().runRegion(session.center(), session::tick);
738+
this.plugin.scheduler().runRegion(session.center(), () -> this.tickSession(session));
739739
} catch (RuntimeException dispatchException) {
740740
org.bukkit.Bukkit.getLogger().log(
741741
java.util.logging.Level.WARNING,
742742
"Failed to schedule tick for table " + session.id(),
743743
dispatchException
744744
);
745745
}
746-
// Bot watchdog: if the session is started but has no armed bot task,
747-
// the bot scheduler may have stalled (e.g. after an unhandled
748-
// exception in a previous callback). Re-schedule to recover.
749-
if (session.isStarted() && !session.hasArmedBotTask()) {
750-
try {
751-
BotActionScheduler.schedule(session);
752-
} catch (RuntimeException watchdogException) {
753-
org.bukkit.Bukkit.getLogger().log(
754-
java.util.logging.Level.WARNING,
755-
"Bot watchdog failed for table " + session.id(),
756-
watchdogException
757-
);
758-
}
746+
}
747+
}
748+
749+
private void tickSession(MahjongTableSession session) {
750+
try {
751+
session.tick();
752+
} catch (RuntimeException tickException) {
753+
org.bukkit.Bukkit.getLogger().log(
754+
java.util.logging.Level.WARNING,
755+
"Tick failed for table " + session.id(),
756+
tickException
757+
);
758+
}
759+
// Bot watchdog: if the session is started but has no armed bot task,
760+
// the bot scheduler may have stalled (e.g. after an unhandled
761+
// exception in a previous callback). Re-schedule to recover.
762+
// This runs on the region thread alongside tick() so that game state
763+
// (engine, currentPlayer, etc.) is accessed from the owning thread.
764+
if (session.isStarted() && !session.hasArmedBotTask()) {
765+
try {
766+
BotActionScheduler.schedule(session);
767+
} catch (RuntimeException watchdogException) {
768+
org.bukkit.Bukkit.getLogger().log(
769+
java.util.logging.Level.WARNING,
770+
"Bot watchdog failed for table " + session.id(),
771+
watchdogException
772+
);
759773
}
760774
}
761775
}

src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ public final class MahjongTableSession implements TableSessionMutator, TableMemb
8585
private PublicActionAnnouncement lastPublicActionAnnouncement;
8686
private PluginTask publicActionClearTask;
8787
private long publicActionAnnouncementSequence;
88-
private PluginTask botTask;
88+
private volatile PluginTask botTask;
8989
private final TableRenderCoordinator renderCoordinator;
9090
private final TableViewerPresentationCoordinator viewerPresentation;
9191
private final TableRegionDisplayCoordinator regionDisplayCoordinator;
@@ -1003,7 +1003,7 @@ public String gbSuggestedKanTile(UUID playerId) {
10031003
return this.fromGbController(playerId, GbTableRoundController::suggestedBotKanTile, null);
10041004
}
10051005

1006-
public void setBotTask(PluginTask botTask) {
1006+
public synchronized void setBotTask(PluginTask botTask) {
10071007
PluginTask old = this.botTask;
10081008
if (old != null && old != botTask) {
10091009
old.cancel();
@@ -1016,7 +1016,7 @@ public void setBotTask(PluginTask botTask) {
10161016
* task. This is used by bot callbacks to avoid clobbering a new task that
10171017
* was set by the render cycle during execution.
10181018
*/
1019-
public void clearBotTaskIfSame(PluginTask expected) {
1019+
public synchronized void clearBotTaskIfSame(PluginTask expected) {
10201020
if (this.botTask == expected) {
10211021
this.botTask = null;
10221022
}
@@ -1032,7 +1032,7 @@ public boolean hasArmedBotTask() {
10321032
return current != null && !current.isCancelled();
10331033
}
10341034

1035-
public void cancelBotTask() {
1035+
public synchronized void cancelBotTask() {
10361036
if (this.botTask != null) {
10371037
this.botTask.cancel();
10381038
this.botTask = null;

0 commit comments

Comments
 (0)