From a83543853c61c4fe6754f2c0d3c843cc4671a8b3 Mon Sep 17 00:00:00 2001 From: laforetbrut Date: Wed, 22 Apr 2026 06:09:08 +0200 Subject: [PATCH] Phase 6: docs (CHANGELOG, ERROR_LOG, TEST_PROCEDURE) Adds three documentation files covering the Phase 0-5 hardening work: CHANGELOG.md - Bilingual EN/FR, strict template (English first, then ---, then French). - Version section 2.1.5 dated 2026-04-22 (NO version bump per CLAUDE.md version-lock rule). - Sections: Fixed / Added / Changed / Correctifs / Ajouts / Modifications. ERROR_LOG.md - Journal of 8 bugs discovered and fixed during the hardening sweep. - Each entry: Context / Error / Root cause / Fix / Prevention rule. - Cross-references commits bea5f80 / c84f920 / 746cb56 / c70ca9f / bd0482c. TEST_PROCEDURE_v2.1.5.html - Self-contained HTML (no external deps), bilingual EN/FR. - 10 test scenarios tagged CRITICAL / HIGH / MEDIUM with Setup, Steps, Expected Results, and a regression-check block. - Covers: drop+deco+reco, backpack dup, SS shulker dup, kill -9 recovery, zombie-peer short-circuit, periodic save, pool stats, heartbeat, curios cap unavailable, cross-server claim. --- CHANGELOG.md | 61 +++++ ERROR_LOG.md | 141 ++++++++++ TEST_PROCEDURE_v2.1.5.html | 523 +++++++++++++++++++++++++++++++++++++ 3 files changed, 725 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 ERROR_LOG.md create mode 100644 TEST_PROCEDURE_v2.1.5.html diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..45abc4e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# Changelog + +All notable changes to **PlayerSync** are documented here. + +--- + +## [2.1.5] - 2026-04-22 + +### Fixed (English first) + +- **Critical item duplication on drop + quick disconnect + reconnect** — Race condition between the auto-save background task and the logout background task could commit a stale snapshot AFTER the logout save, resurrecting dropped items. Triple guard now applied: `pendingLogoutSaves` check (early + under lock) and `SELECT online FROM player_data` skip if logout already committed. Logout BG now acquires `bgLock` with blocking `.lock()` for proper serialization. +- **Backpack / Sophisticated Storage merge-on-restore duplication** — `setBackpackContents` / `setStorageContents` upstream are shallow merges, not replaces. Restore now calls `removeBackpackContents` / `removeStorageContents` (with reflection fallback if absent) AND passes a defensive NBT copy. Fixes mass-duplication of items in backpacks/shulkers on every cross-server transfer. +- **Cross-server save overwrite** — When `writeSnapshotToDB`'s `last_server` guard blocked the core player_data UPDATE, the downstream backpack/SS/RS2 saves still executed and overwrote the claiming server's data. The function now returns a boolean; all 5 callers short-circuit downstream writes on guard block. +- **30-second join delay on zombie peer servers** — `doPlayerJoin` poll waited the full 60 attempts (30s) for server_ids that no longer existed (legacy `server_id=0` rows, or peers that crashed without clearing `online=0`). New `isPeerServerStale` check (peer_id=0 OR heartbeat >60s) takes over immediately and force-clears the orphaned flag. Poll max raised from 60 to 120 attempts (60s) for legitimate slow shutdowns. +- **Curios wipe on dead player** — Legacy `StoreCurios` wrote an empty flatMap when the Curios capability was unavailable, wiping DB data. Now early-returns with a WARN log. + +### Added + +- **JVM shutdown hook (kill -9 / OOM / SIGTERM recovery)** — New `CrashRecovery.installShutdownHook` registers a non-daemon hook that calls `VanillaSync.emergencyFlushAll` synchronously to snapshot and write every online player before process exit. Marks `server_info.enable=0` so peers detect the shutdown. +- **Startup orphan-flag recovery** — `CrashRecovery.clearOrphanedOnlineFlags` runs at `onServerStarting` to clear any `player_data.online=1` rows left by a previous ungraceful exit. Logs the count via `SyncLogger`. +- **Zombie-peer reporter** — `CrashRecovery.reportZombiePeers` logs peer `server_id`s whose heartbeat is stale or missing at boot time. +- **Server heartbeat service** — `HeartbeatService` pings `server_info.last_update` every 10 seconds so peer servers can distinguish live from dead via the new `isPeerServerStale` check. +- **Periodic full-save scheduler** — `PeriodicSaveService` triggers a complete save (player data + backpacks + SS + RS2) for every online synced player every `auto_save_interval_minutes` (new config, default 10, range 0-1440). Independent of NeoForge's vanilla `PlayerEvent.SaveToFile` cadence. +- **Dimension-change save trigger** — New `onPlayerChangeDimension` handler, gated by `save_on_dimension_change` config (default false). Protects against mid-teleport crashes. +- **Executor + HikariCP pool stats reporter** — `PoolStatsReporter` logs `[POOL] executor active/queue/idle, hikari active/idle` every 5 minutes. WARN thresholds trigger when queue >400/512 or Hikari active >=14/15. +- **Structured logging events** — `SyncLogger` gained `containerForceClosed`, `modCompatSkip`, `modCompatSaved`, `modCompatRestored`, `storageSave`, `poolStats`, `warnPlayer`, `nbtAnomaly` for finer-grained diagnostics. + +### Changed + +- **`writeSnapshotToDB` signature** — Now returns `boolean` instead of `void`. `true` means the core UPDATE persisted, `false` means the `last_server` guard blocked. All callers MUST check the return before firing downstream backpack/SS/RS2 writes. +- **Default `auto_save_interval_minutes`** — 10 min (new config key). Trades data-loss window on crash for DB load. Set to 0 to disable. +- **Backpack / SS restore** — Now uses two-step clear (public API + reflection fallback) and defensive NBT copy before upstream setter. Full log line per restore with `cleared_via=api|reflection` and `nbt_keys=N`. + +--- + +### Correctifs (French mirror) + +- **Duplication d'items critique lors d'un drop + déconnexion rapide + reconnexion** — Race condition entre la task auto-save background et la task logout background pouvait commiter un snapshot périmé APRÈS le save logout, ressuscitant les items drop. Triple garde maintenant appliquée : check `pendingLogoutSaves` (early + sous lock) et skip via `SELECT online FROM player_data` si le logout a déjà commité. La task logout BG acquiert maintenant `bgLock` en blocking `.lock()` pour sérialiser proprement. +- **Duplication Backpack / Sophisticated Storage par merge au restore** — `setBackpackContents` / `setStorageContents` en amont sont des merges shallow, pas des replaces. Le restore appelle maintenant `removeBackpackContents` / `removeStorageContents` (avec fallback reflection si absent) ET passe une copie défensive du NBT. Corrige la duplication massive d'items dans les backpacks/shulkers à chaque transfert cross-server. +- **Écrasement cross-server des saves** — Quand le guard `last_server` de `writeSnapshotToDB` bloquait l'UPDATE core player_data, les saves downstream backpack/SS/RS2 s'exécutaient quand même et écrasaient les données du serveur ayant claim. La fonction retourne maintenant un boolean ; les 5 callers court-circuitent les writes downstream en cas de guard block. +- **Délai de 30 secondes à la connexion sur serveurs zombies** — Le poll `doPlayerJoin` attendait les 60 tentatives (30s) pour des `server_id` n'existant plus (lignes legacy `server_id=0`, ou peers ayant crashé sans clear `online=0`). Nouveau check `isPeerServerStale` (peer_id=0 OU heartbeat >60s) prend la main immédiatement et force-clear le flag orphelin. Poll max passé de 60 à 120 tentatives (60s) pour couvrir les shutdowns lents légitimes. +- **Wipe Curios sur joueur mort** — La méthode legacy `StoreCurios` écrivait un flatMap vide quand la capability Curios était absente, wipant les données DB. Elle early-return maintenant avec un log WARN. + +### Ajouts (French mirror) + +- **Hook JVM shutdown (kill -9 / OOM / SIGTERM recovery)** — Nouveau `CrashRecovery.installShutdownHook` enregistre un hook non-daemon qui appelle `VanillaSync.emergencyFlushAll` synchronement pour snapshot et écrire chaque joueur online avant la fin du process. Marque `server_info.enable=0` pour que les peers détectent le shutdown. +- **Recovery des flags orphelins au boot** — `CrashRecovery.clearOrphanedOnlineFlags` tourne au `onServerStarting` pour clear les rows `player_data.online=1` laissées par une sortie ungracieuse précédente. Log le compte via `SyncLogger`. +- **Reporter de peers zombies** — `CrashRecovery.reportZombiePeers` log les `server_id` peers dont le heartbeat est stale ou absent au boot. +- **Service heartbeat** — `HeartbeatService` ping `server_info.last_update` toutes les 10 secondes pour que les peers distinguent live vs dead via le nouveau check `isPeerServerStale`. +- **Scheduler de sauvegarde périodique** — `PeriodicSaveService` déclenche une save complète (player data + backpacks + SS + RS2) pour chaque joueur online synced toutes les `auto_save_interval_minutes` (nouvelle config, défaut 10, plage 0-1440). Indépendant de la cadence vanilla `PlayerEvent.SaveToFile` de NeoForge. +- **Trigger save sur changement de dimension** — Nouveau handler `onPlayerChangeDimension`, gated par la config `save_on_dimension_change` (défaut false). Protège contre les crashes en plein téléport. +- **Reporter stats executor + HikariCP** — `PoolStatsReporter` log `[POOL] executor active/queue/idle, hikari active/idle` toutes les 5 min. Seuils WARN quand queue >400/512 ou Hikari active >=14/15. +- **Événements structurés** — `SyncLogger` a gagné `containerForceClosed`, `modCompatSkip`, `modCompatSaved`, `modCompatRestored`, `storageSave`, `poolStats`, `warnPlayer`, `nbtAnomaly` pour un diagnostic plus fin. + +### Modifications + +- **Signature `writeSnapshotToDB`** — Retourne maintenant `boolean` au lieu de `void`. `true` = l'UPDATE core a persisté, `false` = le guard `last_server` a bloqué. Tous les callers DOIVENT vérifier le retour avant de déclencher les writes downstream backpack/SS/RS2. +- **Défaut `auto_save_interval_minutes`** — 10 min (nouvelle clé config). Trade-off entre fenêtre de perte de données sur crash et charge DB. 0 pour désactiver. +- **Restore Backpack / SS** — Utilise maintenant un clear en deux étapes (API publique + fallback reflection) et une copie défensive NBT avant le setter upstream. Log complet par restore avec `cleared_via=api|reflection` et `nbt_keys=N`. + +--- diff --git a/ERROR_LOG.md b/ERROR_LOG.md new file mode 100644 index 0000000..275710a --- /dev/null +++ b/ERROR_LOG.md @@ -0,0 +1,141 @@ +# PlayerSync — Error Log + +Journal des erreurs rencontrées et corrigées. Chaque entrée documente un bug, sa cause racine, son correctif et la règle de prévention à appliquer systématiquement. + +--- + +## [2026-04-22 02:54] — Item duplication on drop + quick disconnect + reconnect + +**Context** : Un joueur drop un item au sol, se déconnecte très rapidement, puis se reconnecte → l'item est présent deux fois (en inventory restauré + encore au sol). + +**Error** : Duplication systématique reproductible en production. + +**Root cause** : Race condition entre `onPlayerSaveToFile` background task (auto-save périodique) et `onPlayerLogout` background task. +1. `SaveToFile` capture un snapshot sur main thread AVANT le drop (item encore en inventory) → task async soumise. +2. Le joueur drop l'item → inventory vide, ItemEntity dans le monde. +3. Le joueur disconnect → logout capture un snapshot FRESH (sans item), soumet le write. +4. Les deux BG tasks s'exécutent en parallèle. Si la task auto-save (qui portait une snapshot STALE avec l'item) commit APRÈS la task logout (qui portait FRESH sans l'item), la DB finit en STALE. +5. Reconnexion → inventory restauré avec l'item + ItemEntity toujours au sol → 2 copies. + +**Fix** (commit `bea5f80`) : Triple guard dans l'auto-save BG task : +- Early skip si `pendingLogoutSaves.containsKey(uuid)` avant tryLock. +- Re-check sous lock après tryLock (race window fermée). +- `SELECT online FROM player_data WHERE uuid=?` — skip si online=0 (logout a committé). + +Logout BG task acquiert maintenant `bgLock.lock()` (blocking) pour sérialiser proprement avec les auto-save BG qui utilisent `tryLock`. `removePlayerLock` réordonné avant `bgLock.unlock()` pour que les auto-save BG qui wake après unlock voient `containsKey=false` et skip. + +**Prevention** : **JAMAIS de BG task qui modifie la DB sans un guard `online=0` + `pendingLogoutSaves` check**. Si deux paths peuvent écrire le même row, ils DOIVENT partager un lock blocking OU le path "fresh" doit être détectable via DB state (online flag, version column). + +--- + +## [2026-04-22 03:15] — Backpack duplication on cross-server transfer + +**Context** : Un joueur utilise un backpack Sophisticated Backpacks sur Server A, change de serveur, et constate que le contenu du backpack est dupliqué. + +**Error** : Duplication systématique d'items dans les backpacks et shulkers Sophisticated Storage lors de transferts cross-server ou reconnexions. + +**Root cause** : `BackpackStorage.setBackpackContents()` et `ItemContentsStorage.setStorageContents()` en amont sont des **merges shallow**, pas des replaces. Quand le restore applique le snapshot sauvegardé, il MERGE avec les contents existants en mémoire (SavedData persistée sur disk localement ou vue ouverte par un autre joueur). Les sous-tags "items" survivent → duplication. + +**Fix** (commit `c84f920`) : +- Backpack : appel `store.removeBackpackContents(uuid)` EXPLICITE avant `setBackpackContents`. Si l'API throw (absent dans certaines versions), fallback reflection qui parcourt les champs `Map` de `BackpackStorage` et remove l'entrée directement. +- SS : nouveau helper `clearSSStorageContents` qui tente `removeStorageContents(UUID)` via reflection, puis fallback reflection sur champs Map. `setDirty()` appelé pour forcer le flush. +- Les deux paths passent maintenant une **copie défensive** (`nbt.copy()`) à l'upstream setter, jamais la référence partagée. + +**Prevention** : +- **Toujours clear avant restore pour toute structure qui merge au lieu de replace** (backpack, SS, RS2 disks). +- **Toujours passer une copie défensive** d'un CompoundTag à un setter qui peut la stocker en interne. +- **Logger `clear_via=api/reflection`** pour diagnostiquer les régressions upstream. + +--- + +## [2026-04-22 03:20] — Cross-server saves can overwrite claimed data + +**Context** : Deux serveurs sauvent un même joueur simultanément (edge case lors de changements de serveurs rapides). + +**Error** : Les données de l'un écrasent silencieusement les données de l'autre. Backpack/SS/RS2 perdus. + +**Root cause** : `writeSnapshotToDB` retournait `void`. Même si son guard `last_server=?` bloquait le write du core player_data (rows affected = 0), les appels downstream `saveBackpackSnapshots` / `saveSSSnapshots` / `saveRS2DisksByLevel` s'exécutaient INCONDITIONNELLEMENT et écrasaient `backpack_data` (qui n'a pas de guard propre — keyé par storage UUID, pas player UUID). + +**Fix** (commit `c84f920`) : `writeSnapshotToDB` retourne maintenant `boolean`. Les 5 callers (logout, shutdown, auto-save SaveToFile, staggered auto-save, death-save) vérifient le retour et **short-circuitent** les writes downstream si le core a été blocké. + +**Prevention** : **Une fonction qui a un guard silencieux DOIT signaler son résultat au caller**. Ne jamais supposer que les writes downstream sont implicitement protégés par un guard en amont — vérifier explicitement. + +--- + +## [2026-04-22 03:25] — 30s delay on player join (RACE timeout 60/60) + +**Context** : À chaque connexion, log flood `Waiting for server X to finish saving (attempt 60/60)` et le joueur attend 30s avant de récupérer ses données. + +**Error** : Poll timeout systématique sur des server_ids qui n'existent plus ou sur un server_id=0. + +**Root cause** : +- Le poll `doPlayerJoin` attend que l'autre serveur clear `online=0`. Si l'autre serveur a crashé sans le faire (pas de shutdown hook), le poll attend jusqu'à épuisement des 60 tentatives. +- `server_id=0` est une ligne orpheline héritée d'une écriture legacy (avant que le default `Random().nextInt(1, MAX-1)` soit appliqué). + +**Fix** (commit `c84f920`) : +- Nouvelle méthode `isPeerServerStale(peerId, staleMs)` qui check `server_info.last_update`. Si l'heartbeat est vieux de >60s OU si `peerId == 0`, le poll considère le serveur comme zombie et force-clear `online=0`. +- Poll max passé de 60 à 120 tentatives (60s total) pour couvrir les shutdowns lents. +- Phase 3 : `HeartbeatService` tick toutes les 10s → permet aux peers de détecter les zombies. +- Phase 3 : `CrashRecovery.clearOrphanedOnlineFlags()` au boot → nettoie les rows stuck à online=1 après un crash ungracieux. + +**Prevention** : **Tout état "en cours" en DB doit avoir un heartbeat OU un timeout**. Un flag `online=1` sans heartbeat est un bug en attendant de se produire (le process qui l'a set peut crasher). + +--- + +## [2026-04-22 03:30] — StoreCurios NPE / data wipe on dead player + +**Context** : Un joueur meurt puis se déconnecte rapidement. Son curios sont vidés de la DB. + +**Error** : Méthode legacy `StoreCurios` écrivait un flatMap vide quand `CuriosApi.getCuriosInventory(player)` retournait un `Optional.empty()` (capability détachée après death). + +**Root cause** : La méthode utilisait `handlerOpt.ifPresent(...)` mais fallait au `REPLACE INTO` même si le flatMap était vide → wipe DB data pour un joueur mort. + +**Fix** (commit `c84f920`) : Early return avec log `WARN [store-curios] handler unavailable for UUID — skipping write to avoid wiping DB data` si `handlerOpt.isEmpty()`. + +**Prevention** : **Ne JAMAIS écrire un état "vide" dans la DB si la source est incertaine**. Une capability absente ≠ joueur sans curios — c'est un état indéterminé. Skip write + log. + +--- + +## [2026-04-22 03:40] — Player data loss on kill -9 / OOM + +**Context** : Process serveur tué via `kill -9` ou OOM — au redémarrage, les joueurs qui étaient online ne récupèrent pas leurs données des dernières minutes. + +**Error** : `ServerStoppingEvent` n'est pas déclenché lors d'un kill ungracieux, donc aucune save n'est exécutée. Les rows `player_data` restent aussi à `online=1` → le poll de doPlayerJoin sur un autre serveur attend 30s pour rien. + +**Fix** (commit `746cb56`, Phase 3) : +- `CrashRecovery.installShutdownHook(() -> emergencyFlushAll())` — JVM hook non-daemon enregistré au boot. Appelle une méthode synchrone qui snapshot et write tous les joueurs online sans passer par l'executor (qui peut être déjà mort). +- Marque `server_info.enable=0` à la sortie pour notifier les peers. +- `CrashRecovery.clearOrphanedOnlineFlags()` au boot suivant — clear les rows stuck et log le nombre via SyncLogger. +- `HeartbeatService` tick toutes les 10s pendant le run — permet aux peers de détecter la mort. + +**Prevention** : +- **Tout process long-running doit avoir un JVM shutdown hook** pour couvrir SIGTERM / kill doux / OOM soft. +- **Tout flag "en cours" persistant doit avoir un recovery path au boot suivant**. +- **Un heartbeat périodique est obligatoire** si d'autres processus dépendent de savoir si on est alive. + +--- + +## [2026-04-22 03:50] — Inventory loss window of 30 min between auto-saves + +**Context** : Les auto-saves ne se déclenchaient que lors des PlayerEvent.SaveToFile natifs (cadence vanilla = autosave world, typiquement 6000 ticks). Si un crash survenait entre deux saves, jusqu'à 15+ minutes de jeu étaient perdus. + +**Fix** (commit `c70ca9f`, Phase 4) : +- `PeriodicSaveService` — scheduler indépendant qui déclenche un full-flush toutes les `auto_save_interval_minutes` (défaut 10). Hops au main thread pour snapshotter, puis soumet les writes async via `snapshotAndQueueSave`. +- `onPlayerChangeDimension` — trigger additionnel gated par `save_on_dimension_change` (défaut false). Sauve avant teleport cross-dimension. + +**Prevention** : **Ne jamais dépendre uniquement des events du framework** pour déclencher une sauvegarde critique. Doubler avec un scheduler indépendant et rendre l'intervalle configurable. + +--- + +## [2026-04-22 04:00] — Executor queue saturation invisible + +**Context** : Sous charge (35+ joueurs), l'executor `PlayerSync` peut saturer (queue >400) et déclencher `CallerRunsPolicy` qui bloque le main thread. Aucune alerte dans les logs. + +**Fix** (commit `bd0482c`, Phase 5) : +- `PoolStatsReporter` — scheduler dédié 5-min qui log `[POOL] executor active/queue/idle, hikari active/idle`. +- WARN log si queue > 400/512 ou hikari active >= 14/15. +- Accesseur `JDBCsetUp.getPoolMXBean()` pour exposer Hikari en read-only. + +**Prevention** : **Tout pool/queue critique doit être monitoré périodiquement** avec des seuils d'alerte sous la capacité max. Invisible ≠ sain. + +--- diff --git a/TEST_PROCEDURE_v2.1.5.html b/TEST_PROCEDURE_v2.1.5.html new file mode 100644 index 0000000..21b26a8 --- /dev/null +++ b/TEST_PROCEDURE_v2.1.5.html @@ -0,0 +1,523 @@ + + + + +Test Procedure — PlayerSync v2.1.5 + + + + +

Test Procedure — PlayerSync v2.1.5

+
Date : 2026-04-22  |  Branch: 1.21.1-dev  |  Minecraft 1.21.1 / NeoForge 21.1.137 / Java 21
+ +

Setup

+ +
    +
  1. Démarrer MariaDB dev : docker compose up -d
  2. +
  3. Build : ./gradlew build — le JAR apparaît dans build/libs/playersync-1.21.1-2.1.5.jar
  4. +
  5. Deux instances serveur nécessaires : ./gradlew runServer (Server A) + copie avec Server_id différent dans run-2/config/playersync-common.toml (Server B)
  6. +
  7. Adminer : http://localhost:8080 (login playersync/playersync)
  8. +
  9. Monitorer en continu : tail -f run/logs/playersync/sync.log
  10. +
+ +

Scenarios to test

+ +
+ CRITICAL +

1. Drop + deco rapide + reco (regression Phase 0)

+
+ Steps: +
    +
  1. Join Server A, fill inventory with a diamond sword
  2. +
  3. Drop the sword with Q
  4. +
  5. Immediately disconnect (within 1 second)
  6. +
  7. Rejoin Server A
  8. +
+
+
+ Expected: +
    +
  • Inventory does NOT contain the sword
  • +
  • The ItemEntity is still on the ground where dropped
  • +
  • Player has exactly 1 copy of the sword total
  • +
  • Log shows [SAVE] LOGOUT completed then either no SaveToFile BG write or a [GUARD] SaveToFile BG skipped — player already offline in DB
  • +
+
+
+ +
+ CRITICAL +

2. Backpack duplication (Sophisticated Backpacks)

+
+ Steps: +
    +
  1. Join Server A, craft a SophisticatedBackpack, fill with 10 diamond blocks
  2. +
  3. Disconnect
  4. +
  5. Join Server B (configure different Server_id)
  6. +
  7. Open backpack, count diamond blocks
  8. +
+
+
+ Expected: +
    +
  • Exactly 10 diamond blocks (no duplication)
  • +
  • Log shows [restore-backpack] uuid=... nbt_keys=... cleared_via=api (or reflection as fallback)
  • +
  • No WARN about failed removeBackpackContents
  • +
+
+
+ +
+ CRITICAL +

3. Sophisticated Storage shulker duplication

+
+ Steps: +
    +
  1. Join Server A, pack a diamond-filled shulker into your inventory
  2. +
  3. Have Player B (on same server) open your inventory via admin / trade / viewer
  4. +
  5. Disconnect Player A
  6. +
  7. Player A reconnects to Server B
  8. +
  9. Unpack shulker, count diamonds
  10. +
+
+
+ Expected: +
    +
  • Exactly original diamond count
  • +
  • Log shows [CONTAINER_CLOSE] for Player B (viewer forced closed)
  • +
  • Log shows [restore-ss] uuid=... nbt_keys=...
  • +
+
+
+ +
+ CRITICAL +

4. Kill -9 / OOM recovery

+
+ Steps: +
    +
  1. Join Server A, set inventory to known state (put a named diamond)
  2. +
  3. Find server java PID : jps | grep Forge
  4. +
  5. Kill brutally : kill -9 <pid> (or Task Manager → End Task on Windows)
  6. +
  7. Restart server A
  8. +
  9. Join Server A, check inventory
  10. +
+
+
+ Expected: +
    +
  • On startup log: [crash-recovery] cleared N orphan online=1 rows
  • +
  • On startup log: [crash-recovery] JVM shutdown hook installed AND ideally (if hook ran): [emergency-flush] flushed N players
  • +
  • Inventory matches last state before kill (within ~10 min auto-save window)
  • +
+
+
+ +
+ HIGH +

5. Zombie peer server join (no 30s wait)

+
+ Steps: +
    +
  1. In Adminer, manually set player_data.last_server=99999 and online=1 for a test UUID
  2. +
  3. Join any running server with that UUID
  4. +
+
+
+ Expected: +
    +
  • Join happens within a few seconds (not 30s)
  • +
  • Log shows [RACE] Peer server 99999 is dead/zombie — taking over
  • +
  • DB now shows last_server=<thisServer>, online=1
  • +
+
+
+ +
+ HIGH +

6. Periodic auto-save (10 min)

+
+ Steps: +
    +
  1. Set auto_save_interval_minutes=1 in config for quick test
  2. +
  3. Join server, add items to inventory
  4. +
  5. Wait 1 minute (watch sync.log)
  6. +
  7. Kill -9 server
  8. +
  9. Restart, rejoin, check inventory
  10. +
+
+
+ Expected: +
    +
  • Log shows [periodic-save] queued snapshots for N player(s) after 1 min
  • +
  • Post-crash inventory reflects the state AT the last periodic tick
  • +
+
+
+ +
+ HIGH +

7. Pool saturation WARN log

+
+ Steps: +
    +
  1. Wait 5 minutes after server start (for first PoolStatsReporter tick)
  2. +
  3. Grep sync.log for [POOL]
  4. +
+
+
+ Expected: +
    +
  • At least one line like [POOL] executor active=0 queue=0 pool_idle=4 | hikari active=0 idle=4
  • +
  • No WARN unless under load
  • +
+
+
+ +
+ HIGH +

8. Heartbeat updates server_info

+
+ Steps: +
    +
  1. In Adminer, watch server_info.last_update for this server's id
  2. +
  3. Refresh every 20s for 1 minute
  4. +
+
+
+ Expected: +
    +
  • last_update advances by ~10000 ms at every refresh
  • +
  • Log shows [heartbeat] started on boot
  • +
+
+
+ +
+ MEDIUM +

9. Curios capability unavailable — no wipe

+
+ Steps: +
    +
  1. Equip curios items, die in lava
  2. +
  3. Force-disconnect during death animation
  4. +
  5. Reconnect
  6. +
+
+
+ Expected: +
    +
  • If cap was unavailable: log shows [store-curios] handler unavailable for ... skipping write
  • +
  • Curios row in DB NOT wiped
  • +
+
+
+ +
+ MEDIUM +

10. Cross-server claim + downstream short-circuit

+
+ Steps: +
    +
  1. Player connected on Server A
  2. +
  3. Disconnect then immediately join Server B (within 200ms)
  4. +
  5. Check sync.log on Server A
  6. +
+
+
+ Expected: +
    +
  • Server A may log [GUARD] (last_server guard blocked) if B claimed during A's save
  • +
  • If blocked: [SAVE_SKIP] LOGOUT skipped: core guard blocked
  • +
  • Player inventory on B = inventory as it was on A (no merge, no overwrite)
  • +
+
+
+ +

Regression checks

+ +
+Watch for these regressions after Phase 0-5 deployment: + +
+ +
+ + + + + +

Procédure de Test — PlayerSync v2.1.5 (Version Française)

+
Date : 2026-04-22  |  Branche : 1.21.1-dev  |  Minecraft 1.21.1 / NeoForge 21.1.137 / Java 21
+ +

Mise en place

+ +
    +
  1. Démarrer MariaDB dev : docker compose up -d
  2. +
  3. Build : ./gradlew build — le JAR sort dans build/libs/playersync-1.21.1-2.1.5.jar
  4. +
  5. Deux instances serveur nécessaires : ./gradlew runServer (Serveur A) + copie avec Server_id différent dans run-2/config/playersync-common.toml (Serveur B)
  6. +
  7. Adminer : http://localhost:8080 (login playersync/playersync)
  8. +
  9. Monitorer en continu : tail -f run/logs/playersync/sync.log
  10. +
+ +

Scénarios à tester

+ +
+ CRITIQUE +

1. Drop + déco rapide + reco (régression Phase 0)

+
+ Étapes : +
    +
  1. Join Serveur A, remplir l'inventaire avec une épée de diamant
  2. +
  3. Drop l'épée avec Q
  4. +
  5. Déconnecter immédiatement (moins d'une seconde)
  6. +
  7. Rejoin Serveur A
  8. +
+
+
+ Résultat attendu : +
    +
  • L'inventaire ne contient PAS l'épée
  • +
  • L'ItemEntity est toujours au sol où elle a été drop
  • +
  • Le joueur a exactement 1 copie de l'épée au total
  • +
  • Logs : [SAVE] LOGOUT completed puis soit aucun write SaveToFile BG, soit [GUARD] SaveToFile BG skipped — player already offline in DB
  • +
+
+
+ +
+ CRITIQUE +

2. Duplication Backpack (Sophisticated Backpacks)

+
+ Étapes : +
    +
  1. Join Serveur A, craft un SophisticatedBackpack, remplir avec 10 blocs de diamant
  2. +
  3. Déconnecter
  4. +
  5. Join Serveur B (configurer un Server_id différent)
  6. +
  7. Ouvrir le backpack, compter les blocs de diamant
  8. +
+
+
+ Résultat attendu : +
    +
  • Exactement 10 blocs de diamant (pas de duplication)
  • +
  • Logs : [restore-backpack] uuid=... nbt_keys=... cleared_via=api (ou reflection en fallback)
  • +
  • Aucun WARN sur un removeBackpackContents raté
  • +
+
+
+ +
+ CRITIQUE +

3. Duplication shulker Sophisticated Storage

+
+ Étapes : +
    +
  1. Join Serveur A, packer un shulker plein de diamants dans l'inventaire
  2. +
  3. Faire ouvrir l'inventaire par un autre Joueur B (via admin / échange / viewer)
  4. +
  5. Déconnecter le Joueur A
  6. +
  7. Joueur A se reconnecte sur Serveur B
  8. +
  9. Déballer le shulker, compter les diamants
  10. +
+
+
+ Résultat attendu : +
    +
  • Compte de diamants identique à l'original
  • +
  • Logs : [CONTAINER_CLOSE] pour le Joueur B (viewer force-fermé)
  • +
  • Logs : [restore-ss] uuid=... nbt_keys=...
  • +
+
+
+ +
+ CRITIQUE +

4. Recovery kill -9 / OOM

+
+ Étapes : +
    +
  1. Join Serveur A, mettre l'inventaire dans un état connu (poser un diamant nommé)
  2. +
  3. Trouver le PID java du serveur : jps | grep Forge
  4. +
  5. Kill brutal : kill -9 <pid> (ou Task Manager → End Task sur Windows)
  6. +
  7. Redémarrer le serveur A
  8. +
  9. Join Serveur A, vérifier l'inventaire
  10. +
+
+
+ Résultat attendu : +
    +
  • Au boot : log [crash-recovery] cleared N orphan online=1 rows
  • +
  • Au boot : [crash-recovery] JVM shutdown hook installed ET idéalement (si le hook a tourné) : [emergency-flush] flushed N players
  • +
  • L'inventaire correspond au dernier état avant kill (dans la fenêtre auto-save ~10 min)
  • +
+
+
+ +
+ HIGH +

5. Join sur serveur peer zombie (pas d'attente 30s)

+
+ Étapes : +
    +
  1. Dans Adminer, setter manuellement player_data.last_server=99999 et online=1 pour un UUID test
  2. +
  3. Joindre n'importe quel serveur en cours avec cet UUID
  4. +
+
+
+ Résultat attendu : +
    +
  • La connexion se fait en quelques secondes (pas 30s)
  • +
  • Logs : [RACE] Peer server 99999 is dead/zombie — taking over
  • +
  • La DB affiche maintenant last_server=<thisServer>, online=1
  • +
+
+
+ +
+ HIGH +

6. Auto-save périodique (10 min)

+
+ Étapes : +
    +
  1. Setter auto_save_interval_minutes=1 en config pour un test rapide
  2. +
  3. Join le serveur, ajouter des items à l'inventaire
  4. +
  5. Attendre 1 minute (surveiller sync.log)
  6. +
  7. Kill -9 du serveur
  8. +
  9. Redémarrer, rejoin, vérifier l'inventaire
  10. +
+
+
+ Résultat attendu : +
    +
  • Log : [periodic-save] queued snapshots for N player(s) après 1 min
  • +
  • L'inventaire post-crash reflète l'état AU dernier tick périodique
  • +
+
+
+ +
+ HIGH +

7. Log WARN sur saturation pool

+
+ Étapes : +
    +
  1. Attendre 5 minutes après le boot du serveur (premier tick PoolStatsReporter)
  2. +
  3. Grep sync.log pour [POOL]
  4. +
+
+
+ Résultat attendu : +
    +
  • Au moins une ligne comme [POOL] executor active=0 queue=0 pool_idle=4 | hikari active=0 idle=4
  • +
  • Aucun WARN sauf sous charge
  • +
+
+
+ +
+ HIGH +

8. Heartbeat update server_info

+
+ Étapes : +
    +
  1. Dans Adminer, surveiller server_info.last_update pour l'id de ce serveur
  2. +
  3. Refresh toutes les 20s pendant 1 minute
  4. +
+
+
+ Résultat attendu : +
    +
  • last_update avance de ~10000 ms à chaque refresh
  • +
  • Log : [heartbeat] started au boot
  • +
+
+
+ +
+ MEDIUM +

9. Capability Curios absente — pas de wipe

+
+ Étapes : +
    +
  1. Équiper des items curios, mourir dans la lave
  2. +
  3. Force-déconnecter pendant l'animation de mort
  4. +
  5. Reconnexion
  6. +
+
+
+ Résultat attendu : +
    +
  • Si cap absente : log [store-curios] handler unavailable for ... skipping write
  • +
  • Row curios en DB NON wipée
  • +
+
+
+ +
+ MEDIUM +

10. Claim cross-server + court-circuit downstream

+
+ Étapes : +
    +
  1. Joueur connecté sur Serveur A
  2. +
  3. Déco puis immédiatement join Serveur B (<200ms)
  4. +
  5. Vérifier sync.log sur Serveur A
  6. +
+
+
+ Résultat attendu : +
    +
  • Serveur A peut logger [GUARD] (last_server guard a bloqué) si B a claim pendant la save de A
  • +
  • Si blocké : [SAVE_SKIP] LOGOUT skipped: core guard blocked
  • +
  • Inventaire joueur sur B = inventaire tel qu'il était sur A (pas de merge, pas d'overwrite)
  • +
+
+
+ +

Vérifications régressions

+ +
+Surveiller ces régressions après le déploiement Phases 0-5 : + +
+ +

+Author: vyrriox  |  PlayerSync v2.1.5  |  2026-04-22 +

+ + +