Fix critical item duplication race (drop+deco+reco)

Root cause: auto-save BG task queued before logout could acquire bgLock and
write a stale snapshot AFTER the logout BG task had committed fresh data +
online=0. On reconnect, the stale inventory was restored while the dropped
ItemEntity remained on the ground -> duplication.

Three-layer guard applied to onPlayerSaveToFile and onLivingDeath BG tasks:
  1. Early skip if pendingLogoutSaves contains the player (before tryLock)
  2. Re-check pendingLogoutSaves after acquiring bgLock (race window)
  3. SELECT online from player_data before write; skip if online=0

Logout BG task now acquires bgLock via .lock() (blocking) so concurrent
auto-save / death-save tasks using tryLock either skip cleanly or wait.
removePlayerLock reordered before bgLock.unlock so late auto-save BGs see
containsKey=false and skip.
This commit is contained in:
laforetbrut 2026-04-22 05:28:36 +02:00
parent f334b44a55
commit bea5f80e3a

View File

@ -906,10 +906,27 @@ public class VanillaSync {
// FIX: If the player already logged out (removePlayerLock was called), // FIX: If the player already logged out (removePlayerLock was called),
// this snapshot is stale and must NOT overwrite the fresher logout snapshot. // this snapshot is stale and must NOT overwrite the fresher logout snapshot.
if (!playerLocks.containsKey(puuid)) return; if (!playerLocks.containsKey(puuid)) return;
// FIX CRITICAL ANTI-DUP (P0-a): early skip if logout is already in flight.
if (pendingLogoutSaves.containsKey(puuid)) return;
ReentrantLock bgLock = getPlayerLock(puuid); ReentrantLock bgLock = getPlayerLock(puuid);
if (!bgLock.tryLock()) return; // another save started, skip if (!bgLock.tryLock()) return; // another save started, skip
try { try {
// FIX CRITICAL ANTI-DUP (P0-b): re-check under lock a logout task may
// have been submitted between the check above and tryLock success.
if (pendingLogoutSaves.containsKey(puuid)) return;
// FIX CRITICAL ANTI-DUP (P0-c): last line of defence if the DB already
// shows online=0, a logout save has committed and any write here would
// resurrect stale data (cause of drop+deco+reco item duplication).
try (JDBCsetUp.QueryResult onlineCheck = JDBCsetUp.executePreparedQuery(
"SELECT online FROM " + Tables.playerData() + " WHERE uuid=?", puuid)) {
ResultSet rs = onlineCheck.resultSet();
if (rs.next() && rs.getInt("online") == 0) {
SyncLogger.guardBlocked(puuid, JdbcConfig.SERVER_ID.get(),
"SaveToFile BG skipped — player already offline in DB (logout committed)");
return;
}
}
writeSnapshotToDB(snapshot); writeSnapshotToDB(snapshot);
} catch (Exception e) { } catch (Exception e) {
PlayerSync.LOGGER.error("Error writing async SaveToFile snapshot for player {}", puuid, e); PlayerSync.LOGGER.error("Error writing async SaveToFile snapshot for player {}", puuid, e);
@ -1174,6 +1191,12 @@ public class VanillaSync {
// stays forever in pendingLogoutSaves and blocks future rejoins for 15s+. // stays forever in pendingLogoutSaves and blocks future rejoins for 15s+.
try { try {
executorService.execute(() -> { executorService.execute(() -> {
// FIX CRITICAL ANTI-DUP (P0-d): acquire bgLock BEFORE any DB write so
// concurrent SaveToFile / death-save BG tasks (using tryLock) either skip
// cleanly OR wait until this logout finishes. Without this, a stale
// auto-save queued before logout could overwrite fresh logout data.
ReentrantLock bgLock = getPlayerLock(player_uuid);
bgLock.lock();
try { try {
// FIX ANTI-DUPLICATION: writeSnapshotToDB with setOffline=true // FIX ANTI-DUPLICATION: writeSnapshotToDB with setOffline=true
// atomically writes data + online=0 in a SINGLE UPDATE, AND guards // atomically writes data + online=0 in a SINGLE UPDATE, AND guards
@ -1198,9 +1221,13 @@ public class VanillaSync {
PlayerSync.LOGGER.error("CRITICAL: Failed to mark player {} offline", player_uuid, e2); PlayerSync.LOGGER.error("CRITICAL: Failed to mark player {} offline", player_uuid, e2);
} }
} finally { } finally {
// FIX P0-d: remove playerLocks BEFORE unlocking bgLock so any
// auto-save BG that wakes right after unlock sees containsKey=false
// and skips cleanly.
removePlayerLock(player_uuid); removePlayerLock(player_uuid);
pendingLogoutSaves.remove(player_uuid); pendingLogoutSaves.remove(player_uuid);
futureRef.complete(null); futureRef.complete(null);
try { bgLock.unlock(); } catch (Exception ignored) {}
} }
}); });
} catch (java.util.concurrent.RejectedExecutionException rex) { } catch (java.util.concurrent.RejectedExecutionException rex) {
@ -1797,9 +1824,23 @@ public class VanillaSync {
executorService.submit(() -> { executorService.submit(() -> {
if (!playerLocks.containsKey(puuid)) return; if (!playerLocks.containsKey(puuid)) return;
// FIX CRITICAL ANTI-DUP (P0-a): early skip if logout is already in flight.
if (pendingLogoutSaves.containsKey(puuid)) return;
ReentrantLock bgLock = getPlayerLock(puuid); ReentrantLock bgLock = getPlayerLock(puuid);
if (!bgLock.tryLock()) return; if (!bgLock.tryLock()) return;
try { try {
// FIX CRITICAL ANTI-DUP (P0-b): re-check under lock.
if (pendingLogoutSaves.containsKey(puuid)) return;
// FIX CRITICAL ANTI-DUP (P0-c): skip if logout has already committed.
try (JDBCsetUp.QueryResult onlineCheck = JDBCsetUp.executePreparedQuery(
"SELECT online FROM " + Tables.playerData() + " WHERE uuid=?", puuid)) {
ResultSet rs = onlineCheck.resultSet();
if (rs.next() && rs.getInt("online") == 0) {
SyncLogger.guardBlocked(puuid, JdbcConfig.SERVER_ID.get(),
"Death-save BG skipped — player already offline in DB");
return;
}
}
writeSnapshotToDB(snapshot); writeSnapshotToDB(snapshot);
ModsSupport.saveBackpackSnapshots(backpackSnapshots); ModsSupport.saveBackpackSnapshots(backpackSnapshots);
ModsSupport.saveSSSnapshots(ssSnapshots); ModsSupport.saveSSSnapshots(ssSnapshots);