PasteOperation deadlock — Folia region thread bloqué indéfiniment #1

Closed
opened 2026-06-04 17:05:23 +00:00 by wait4mi · 1 comment

Symptôme

Le serveur mc-creaclone freeze progressivement : les régions Folia cessent de ticker une à une. Le Folia Watchdog remonte des erreurs du type :

Tick region located in world 'world_nether' around chunk '[1084, -226]' has not responded in 460s

Dans le stacktrace, le thread est bloqué sur Semaphore.acquire() dans PasteOperation.acquireSlot() (ligne 630). Le sémaphore n'est jamais relâché → le thread scheduler de la région attend indéfiniment.

Le bug est cumulatif : plusieurs régions peuvent être affectées simultanément (Overworld + Nether observés). Une région Overworld était bloquée depuis 88h sans que le serveur ne crash.

Impact

Serveur injoinable, nécessite un restart manuel.

À investiguer

  • Condition exacte qui empêche le release() du sémaphore dans PasteOperation
  • Probablement une exception non catchée entre acquireSlot et le release correspondant → ajouter un try/finally
  • Reproduit sur mc-creaclone (preprod)

Références

  • fr.ekaii.litematica.paste.PasteOperation.acquireSlot (ligne 630)
  • fr.ekaii.litematica.paste.FoliaCompat.runOnRegion (lignes 58, 65)
## Symptôme Le serveur `mc-creaclone` freeze progressivement : les régions Folia cessent de ticker une à une. Le Folia Watchdog remonte des erreurs du type : ``` Tick region located in world 'world_nether' around chunk '[1084, -226]' has not responded in 460s ``` Dans le stacktrace, le thread est bloqué sur `Semaphore.acquire()` dans `PasteOperation.acquireSlot()` (ligne 630). Le sémaphore n'est jamais relâché → le thread scheduler de la région attend indéfiniment. Le bug est **cumulatif** : plusieurs régions peuvent être affectées simultanément (Overworld + Nether observés). Une région Overworld était bloquée depuis **88h** sans que le serveur ne crash. ## Impact Serveur injoinable, nécessite un restart manuel. ## À investiguer - Condition exacte qui empêche le `release()` du sémaphore dans `PasteOperation` - Probablement une exception non catchée entre `acquireSlot` et le `release` correspondant → ajouter un `try/finally` - Reproduit sur `mc-creaclone` (preprod) ## Références - `fr.ekaii.litematica.paste.PasteOperation.acquireSlot` (ligne 630) - `fr.ekaii.litematica.paste.FoliaCompat.runOnRegion` (lignes 58, 65)
Owner

Corrigé en v0.4.2

Oui, c'était encore le bug. Le jar déployé sur mc-creaclone était toujours le 0.4.1 vulnérable. Les logs le confirment :

  • Stalls en continu (~17 000 lignes has not responded/jour) du 28/05 au 11/06, puis restart manuel le 11/06 à 18:33 — une région Overworld était bloquée 419 740 s ≈ 116 h.
  • Stacks épinglées à PasteOperation.java:293 (pass 2) et :345 (balayage physique) — pas la boucle pass 1.
  • Chat crea du 11/06, Wait4Mi : « tu l'as refait crash en pastant ? » / « faut vraiment qu'on regle ça ».

C'était dormant uniquement parce qu'aucun gros paste n'a re-déclenché depuis le restart — le code vulnérable tournait toujours.

Cause racine

Le fix v0.4.1 n'avait déplacé hors du thread de région que la boucle de pass 1. Les boucles pass 2 (pass1All.thenCompose(...)) et balayage physique différé (pass2All.thenCompose(...)) utilisaient un thenCompose simple (non-async) → leur corps s'exécute sur le thread qui a complété la future amont, laquelle est complétée par done.complete(null) à l'intérieur de FoliaCompat.runOnRegion, donc sur un thread de tick de région Folia.

Ces boucles appelaient alors le acquireSlot() bloquant sur ce thread de région. Quand la même région avait plus de tâches-chunk en attente que de permits CHUNK_THROTTLE libres (32), elle se garait dans Semaphore.acquire() en attendant des permits que seule elle pouvait libérer en exécutant ses propres tâches en file → auto-deadlock. Cumulatif (Overworld + Nether), et le Watchdog log sans jamais crasher → région gelée indéfiniment.

Correctif (deux couches)

  1. Continuations hors-région — les deux boucles passent désormais par thenComposeAsync(..., offRegionExecutor) (pool async Folia / worker async Paper). Chaque acquireSlot() tourne sur un worker async, jamais un thread de région → un thread bloqué ne peut plus geler une région.
  2. Acquire bornéacquireSlot() utilise tryAcquire(60 s) et renvoie si un permit a réellement été pris. En cas de timeout, le chunk est dispatché sans throttle + warning loggé, au lieu de bloquer indéfiniment. Le garde releasedOnce est initialisé (!acquired) pour ne jamais sur-libérer un permit non pris. Soupape de sécurité : même un permit fuité ne peut que dégrader le throttle, jamais geler un thread.

Aucun changement config/schéma vs 0.4.1 — drop-in. Comportement en charge normale inchangé (throttle de 32 tâches-chunk en vol préservé ; le chemin timeout ne se déclenche jamais en usage normal).

Déployé

Commit 4b87014 (main)
Release v0.4.2+26.1.2 (jar attaché)
creaclone jar 0.4.1 → 0.4.2 swappé, conteneur redémarré 00:43
Statut LitematicaFolia v0.4.2+26.1.2 chargé sans erreur, conteneur healthy, 0 has not responded post-boot

⚠️ Validation : la cause du deadlock est éliminée par analyse + compilation + chargement propre en prod. Le test end-to-end idéal reste un gros paste via un vrai client Litematica/Servux — je n'en avais pas de connecté au moment du déploiement. À confirmer au prochain gros paste, mais le mécanisme d'auto-deadlock n'existe structurellement plus.

## Corrigé en v0.4.2 ✅ **Oui, c'était encore le bug.** Le jar déployé sur `mc-creaclone` était toujours le **0.4.1** vulnérable. Les logs le confirment : - Stalls en continu (~17 000 lignes `has not responded`/jour) du 28/05 au **11/06**, puis restart manuel le 11/06 à 18:33 — une région Overworld était bloquée **419 740 s ≈ 116 h**. - Stacks épinglées à `PasteOperation.java:293` (pass 2) et `:345` (balayage physique) — pas la boucle pass 1. - Chat crea du 11/06, Wait4Mi : *« tu l'as refait crash en pastant ? »* / *« faut vraiment qu'on regle ça »*. C'était dormant uniquement parce qu'aucun gros paste n'a re-déclenché depuis le restart — le code vulnérable tournait toujours. ### Cause racine Le fix v0.4.1 n'avait déplacé hors du thread de région que la boucle de **pass 1**. Les boucles **pass 2** (`pass1All.thenCompose(...)`) et **balayage physique différé** (`pass2All.thenCompose(...)`) utilisaient un `thenCompose` **simple (non-async)** → leur corps s'exécute *sur le thread qui a complété la future amont*, laquelle est complétée par `done.complete(null)` **à l'intérieur de `FoliaCompat.runOnRegion`**, donc **sur un thread de tick de région Folia**. Ces boucles appelaient alors le `acquireSlot()` **bloquant** sur ce thread de région. Quand la même région avait plus de tâches-chunk en attente que de permits `CHUNK_THROTTLE` libres (32), elle se garait dans `Semaphore.acquire()` en attendant des permits que **seule elle** pouvait libérer en exécutant ses propres tâches en file → **auto-deadlock**. Cumulatif (Overworld + Nether), et le Watchdog log sans jamais crasher → région gelée indéfiniment. ### Correctif (deux couches) 1. **Continuations hors-région** — les deux boucles passent désormais par `thenComposeAsync(..., offRegionExecutor)` (pool async Folia / worker async Paper). Chaque `acquireSlot()` tourne sur un worker async, **jamais** un thread de région → un thread bloqué ne peut plus geler une région. 2. **Acquire borné** — `acquireSlot()` utilise `tryAcquire(60 s)` et renvoie si un permit a réellement été pris. En cas de timeout, le chunk est dispatché **sans throttle** + warning loggé, au lieu de bloquer indéfiniment. Le garde `releasedOnce` est initialisé (`!acquired`) pour ne jamais sur-libérer un permit non pris. Soupape de sécurité : même un permit fuité ne peut que dégrader le throttle, jamais geler un thread. Aucun changement config/schéma vs 0.4.1 — drop-in. Comportement en charge normale inchangé (throttle de 32 tâches-chunk en vol préservé ; le chemin timeout ne se déclenche jamais en usage normal). ### Déployé | | | |---|---| | Commit | `4b87014` (main) | | Release | [v0.4.2+26.1.2](https://forgejo.ekaii.fr/admin_ekaii/litematica-folia-ekaii/releases/tag/v0.4.2+26.1.2) (jar attaché) | | creaclone | jar 0.4.1 → 0.4.2 swappé, conteneur redémarré 00:43 | | Statut | `LitematicaFolia v0.4.2+26.1.2` chargé sans erreur, conteneur `healthy`, 0 `has not responded` post-boot | ⚠️ **Validation** : la cause du deadlock est éliminée par analyse + compilation + chargement propre en prod. Le test end-to-end idéal reste un gros paste via un vrai client Litematica/Servux — je n'en avais pas de connecté au moment du déploiement. À confirmer au prochain gros paste, mais le mécanisme d'auto-deadlock n'existe structurellement plus.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
admin_ekaii/litematica-folia-ekaii#1
No description provided.