C2ME Watchdog Crash auf dem Fabric-Server finden und mit einem Mixin fixen | MappleBox

C2ME Watchdog Crash auf dem Fabric-Server finden und mit einem Mixin fixen

Wenn dein modded Fabric-Server plötzlich einfriert und nach einer Minute vom Watchdog (ein eingebauter Aufpasser) gestoppt wird, ist die Ursache oft schwer zu finden. In diesem Guide zeige ich dir einen konkreten Fall. Die Performance-Mod (Erweiterung) C2ME lädt Chunks (Kartenstücke der Welt) auf eigenen Worker-Threads, also parallel laufenden Arbeitssträngen. Dabei greift die Fabric API (eine Grundbibliothek für Fabric-Mods) auf eine Map zu, eine Art Nachschlagetabelle, die nicht für mehrere Threads gebaut ist. Du lernst, wie du den Crash Report liest und wie du kurzfristig reagierst. Danach behebst du das Problem mit einem Mixin, also einem kleinen Patch, der beim Start den Code einer anderen Mod verändert. Als Beispiel nehme ich CobbleBox, meinen Cobblemon-Server auf Minecraft 1.21.1 mit Fabric und dem Modpack COBBLEVERSE. Dort ist genau dieser Crash an einem Tag viermal aufgetreten.

Kurz erklärt

  • Dein Server friert plötzlich ein und stürzt nach einer Minute ab.
  • Schuld sind zwei Teile der Server-Software, die Mod C2ME und die Fabric API, arbeiten gleichzeitig an derselben Liste und kommen sich dabei in die Quere.
  • Der Fix ist eine kleine, selbst gebaute Mod, die nur auf dem Server läuft. Deine Spieler müssen nichts installieren.
  • Hilfreich für alle, die einen Fabric-Server mit vielen Mods betreiben.
  • Du brauchst Zugriff auf Crash Report und Server-Log, etwas Java-Grundwissen und Docker oder ein JDK 21 zum Bauen der Mod. Als Mod-Projekt nimmst du entweder die fertige Mod aus meinem Repo minecraft-weekly-world oder ein eigenes Projekt aus dem Fabric-Template.

Symptom: Server friert ein, Watchdog beendet ihn nach 60 Sekunden

Von außen sieht der Crash immer gleich aus. Spieler bewegen sich noch kurz, dann steht alles. Chat, Mobs und Redstone reagieren nicht mehr. Nach 60 Sekunden greift der Watchdog von Minecraft, schreibt einen Crash Report und beendet den Server. Wenn dein Panel den Server automatisch neu startet, kommen alle nach ein paar Minuten wieder rein, bis es erneut passiert.

Auf CobbleBox kam der Crash am 5. Oktober um 12:20, 20:53 und 22:10 Uhr, jedes Mal in der wöchentlichen Abenteuerwelt. Das ist eine eigene Welt, die über die Mod MultiWorld angelegt wird und jede Woche einen neuen Seed (den Startwert, aus dem die Landschaft erzeugt wird) bekommt. Um 22:43 Uhr traf es dann auch die Hauptwelt, ausgelöst durch einen Waystone-Teleport.

Der Watchdog selbst ist nie die Ursache. Er meldet nur, dass ein Server-Tick (ein einzelner Rechenschritt des Servers) zu lange gedauert hat. Du musst also herausfinden, worauf der Server-Thread gewartet hat.

Den Crash Report richtig lesen

Ein Watchdog-Crash Report enthält die Stack Traces aller Threads, also für jeden Thread, an welcher Stelle im Code er gerade steckt. Der Server-Thread zeigt dir, wo er hängt, und das war hier getChunkBlocking. Er wartet also darauf, dass ein Chunk fertig geladen wird. Danach suchst du den Thread, der diesen Chunk laden sollte. Mit C2ME heißen diese Threads c2me-worker, und im Log taucht davor oft die Meldung Error upgrading chunk auf.

Der Ausschnitt zeigt: Der Server-Thread wartet auf einen Chunk, ein C2ME-Thread bricht mit einem Fehler ab. Die Zeilen sind gekürzt und zeigen nur die Stellen, nach denen du im Report suchst:

Thread "Server thread"
  ... ServerChunkManager.getChunkBlocking   <- wartet auf einen Chunk

Thread "c2me-worker-..."
  java.lang.NullPointerException: ... "this.wrapped" is null
  ... Object2ReferenceOpenHashMap ... removeIf
  ... ServerWorld.fabric_invalidateCache
  ... BlockApiCacheImpl
  ... (Block Entity wird beim Chunk-Laden erzeugt)

Vereinfacht gesagt: Zwei Threads ändern gleichzeitig dieselbe Tabelle. Gelesen von unten nach oben: C2ME lädt einen vollständigen Chunk auf einem Worker-Thread. Dabei wird ein Block Entity geladen (ein Block mit eigenen Daten), und das löst in der Fabric API ein Event aus. Die Fabric API pflegt pro Welt einen Cache (Zwischenspeicher) für ihre Block API Lookups (BlockApiCacheImpl). Diesen Cache muss sie für die Position invalidieren, also als veraltet markieren. Dafür ruft sie fabric_invalidateCache auf, eine Methode, die Fabric per eigenem Mixin in ServerWorld einbaut. Die räumt mit removeIf in einer Object2ReferenceOpenHashMap auf. Diese Map aus fastutil ist nicht thread-safe, verträgt also keine gleichzeitigen Zugriffe. Ändert ein anderer Thread sie gleichzeitig, kippt der Durchlauf durch die Map. Das Ergebnis ist eine NPE (ein Zugriff auf etwas, das nicht existiert) mit "this.wrapped" is null.

Damit wird der Chunk nie fertig, der Server-Thread wartet in getChunkBlocking ewig darauf, und nach 60 Sekunden zieht der Watchdog die Reißleine. Der zweite Thread, der gleichzeitig an der Map arbeitet, ist der Server-Thread selbst. Er registriert über fabric_registerCache neue Caches in derselben Map.

Warum es beim Spielen crasht und beim Pregen nicht

Zwei Beobachtungen haben bei der Suche geholfen. Zuerst trat der Crash nur in der MultiWorld-Welt auf, die intern mit der Library Fantasy erzeugt wird, und nicht in der Hauptwelt. Außerdem hat die Vorgenerierung (Pregen) mit der Mod Chunky den Crash nie ausgelöst. Dabei hat Chunky die Abenteuerwelt mit rund 380 Chunks pro Sekunde durchgerechnet. Ausgelöst haben ihn Spieler, die sich durch die Welt bewegt haben.

Das passt zu einer Race Condition, einem Fehler, der nur auftritt, wenn zwei Abläufe zufällig zusammentreffen. Für den Crash müssen zwei Threads gleichzeitig an derselben Map arbeiten: ein C2ME-Worker, der beim Laden invalidiert, und der Server-Thread, der Caches registriert. Beim Pregen ohne Spieler passiert auf dem Server-Thread in der Welt wenig. Wenn Spieler unterwegs sind, laufen beide Seiten parallel. Je mehr Chunks mit Block Entities geladen werden, desto öfter trifft es. Warum die MultiWorld-Welt anfangs häufiger betroffen war, ließ sich nicht abschließend klären. Der Crash in der Hauptwelt am selben Abend hat aber gezeigt, dass das Problem nicht an MultiWorld hängt. Es kann jede Welt treffen, in der C2ME Chunks lädt.

Sofortmaßnahme: betroffene Welt schließen

Bevor du an einem Fix baust, sorg dafür, dass deine Spieler nicht weiter rausfliegen. Auf CobbleBox wurde die Abenteuerwelt um 22:45 Uhr geschlossen. Der Zugang über den NPC wurde per Scoreboard-Flag gesperrt. Der automatische wöchentliche Wechsel durfte die Welt nicht wieder öffnen, und die Spieler wurden im Chat informiert. Wenn das Problem in einer Zusatzwelt auftritt, ist Schließen der schnellste Weg, deine Spieler davon fernzuhalten. Als Ausgleich gab es für die Betroffenen später eine kleine Entschädigung im Spiel.

Welche Fixes in Frage kamen

Vor dem ersten Code standen diese Optionen zur Wahl:

  • Eine Einstellung in C2ME ändern oder auf ein C2ME-Update hoffen. Das betrifft eine Mod, die im Modpack mitkommt und die du nicht selbst in der Hand hast.
  • Die Wochenwelten als normale Datapack-Dimensionen anlegen statt über MultiWorld und Fantasy. Gemeint sind eigene Welten, die ein Datapack (ein Dateipaket für Minecraft) anlegt. Das wäre ein großer Umbau gewesen und hätte den Crash in der Hauptwelt nicht verhindert.
  • Ein Mixin, das die Invalidierung auf den Server-Thread verschiebt.
  • Ein Mixin, das beide Zugriffe auf die Map hinter einen Lock pro Welt legt. Ein Lock ist eine Sperre, durch die immer nur ein Thread gleichzeitig darf.

Geplant war ursprünglich, den Crash erst auf einem leeren Testserver nachzustellen. Weil der Fehler im Stack Trace so eindeutig war, fiel die Wahl auf die letzte Variante. Sie ändert am wenigsten: Die Fabric API macht weiter genau das, was sie vorher gemacht hat, nur kommen sich die beiden Threads nicht mehr in die Quere. Und sie ließ sich in die ohnehin vorhandene Server-Mod von CobbleBox einbauen.

Das Mod-Projekt: fertige Mod oder eigenes Template

Ein Mixin braucht eine Mod, in der es steckt. Auf CobbleBox war das die ohnehin vorhandene Server-Mod. Du hast zwei Wege.

Weg 1 ist die Mod aus meinem Repo mapplebox/minecraft-weekly-world. Sie gehört eigentlich zur wöchentlichen Abenteuerwelt, enthält aber genau diesen Lock-Mixin als ServerWorldLookupLockMixin. Gesteuert wird er in config/weeklyworld.json über "lookupCacheLock": true, das ist der Standard. Die Weltfunktionen der Mod bleiben ungenutzt, solange du keine Dimensionen mit dem Präfix weekly_ anlegst. Die Mod baut mit dem Gradle Wrapper aus dem Repo (./gradlew build). Schlägt der Build bei dir fehl, mach im Repo ein Issue auf.

git clone https://github.com/mapplebox/minecraft-weekly-world.git
cd minecraft-weekly-world/mod
# bauen wie unten im Abschnitt "Die Mod bauen" beschrieben

Weg 2 ist ein eigenes, schlankes Projekt. Das erzeugst du mit dem offiziellen Template-Generator unter fabricmc.net/develop/template. Trag Mod-Name, Package-Name (zum Beispiel de.example.servermod) und Minecraft-Version 1.21.1 ein. Die Option „Mojang Mappings“ ist dort vorausgewählt. Entfern den Haken, damit das Projekt Yarn-Mappings nutzt, denn der Code in diesem Guide ist mit Yarn-Namen geschrieben. „Split client and common sources“ kannst du für eine reine Server-Mod ebenfalls abwählen. Du bekommst ein Zip mit Gradle-Wrapper, fabric.mod.json, Mixin-Config und Beispiel-Klassen, in das du die Klassen aus diesem Guide legst.

Mappings sind die Namenstabellen, mit denen Minecrafts verschleierter Code lesbare Namen bekommt. Yarn ist die Community-Variante von Fabric, die Mojang Mappings sind die offiziellen Namen von Mojang. Das offizielle fabric-example-mod nutzt inzwischen Mojang Mappings. Kopierst du den Code unten in so ein Projekt, kennt der Compiler Klassen wie ServerWorld oder World.OVERWORLD nicht, dort heißen sie ServerLevel und Level.OVERWORLD. Welche Mappings dein Projekt nutzt, siehst du in der build.gradle: mappings "net.fabricmc:yarn:..." heißt Yarn, mappings loom.officialMojangMappings() heißt Mojang.

Ein Mixin mit Lock pro Welt

Jede ServerWorld bekommt ein eigenes Lock-Objekt. Die beiden Methoden fabric_registerCache und fabric_invalidateCache werden so umhüllt, dass immer nur ein Thread gleichzeitig hineinkommt. Vereinfacht sieht das Mixin so aus, als Datei src/main/java/de/example/servermod/mixin/ServerWorldLookupLockMixin.java (Yarn-Mappings):

package de.example.servermod.mixin;

import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import de.example.servermod.LookupLocked;
import net.fabricmc.fabric.impl.lookup.block.BlockApiCacheImpl;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.util.math.BlockPos;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;

@Mixin(value = ServerWorld.class, priority = 1500)
public abstract class ServerWorldLookupLockMixin implements LookupLocked {
    @Unique
    private final Object example$lookupLock = new Object();

    @WrapMethod(method = "fabric_registerCache", remap = false, require = 0)
    private void example$lockRegister(BlockPos pos, BlockApiCacheImpl<?, ?> cache,
                                      Operation<Void> original) {
        synchronized (example$lookupLock) {
            original.call(pos, cache);
        }
    }

    @WrapMethod(method = "fabric_invalidateCache", remap = false, require = 0)
    private void example$lockInvalidate(BlockPos pos, Operation<Void> original) {
        synchronized (example$lookupLock) {
            original.call(pos);
        }
    }
}

Ein paar Details, auf die du achten solltest:

  • @WrapMethod stammt aus MixinExtras und gibt es ab MixinExtras 0.4.0. Der Fabric Loader bringt diese Version ab 0.16.0 mit, du musst MixinExtras also nicht selbst einbinden. Setz in der fabric.mod.json deshalb "fabricloader": ">=0.16.0". Die Imports sind com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod und com.llamalad7.mixinextras.injector.wrapoperation.Operation. @WrapMethod legt sich um die komplette Methode, und mit original.call(...) rufst du das Original auf. Damit kannst du den ganzen Aufruf in einen synchronized-Block packen.
  • Die beiden Methoden gibt es im Vanilla-Code (dem unveränderten Minecraft) nicht. Die Fabric API fügt sie mit ihrem eigenen Mixin hinzu, und das läuft mit der Standard-Priorität 1000. Dein Mixin bekommt deshalb priority = 1500, damit es danach angewendet wird und die Methoden schon vorhanden sind.
  • remap = false ist nötig, weil die Methodennamen von Fabric stammen und nicht aus den Minecraft-Mappings.
  • require = 0 verhindert, dass der Server nicht startet, falls eine spätere Fabric-API-Version die Methoden umbenennt. Der Fix ist dann allerdings still inaktiv, deshalb brauchst du die Log-Zeile weiter unten.
  • Der Lock gilt pro Welt. Verschiedene Dimensionen blockieren sich also nicht gegenseitig.

Das Interface LookupLocked ist ein leerer Marker, also ein Etikett ohne Code. Der Fix selbst kommt ohne aus, aber damit kannst du beim Start prüfen, ob das Mixin tatsächlich angewendet wurde. Das Interface muss außerhalb des Mixin-Packages liegen. Warum, steht weiter unten. Leg es deshalb als src/main/java/de/example/servermod/LookupLocked.java an:

package de.example.servermod;

public interface LookupLocked {
}

Die Mixin-Config, eine kleine JSON-Datei (Textformat für Einstellungen) unter src/main/resources/, sagt Fabric, welche Mixin-Klasse es laden soll. Das Template legt sie schon als <modid>.mixins.json an. Du trägst dort dein Package und den Namen der Mixin-Klasse ein:

{
  "required": false,
  "minVersion": "0.8",
  "package": "de.example.servermod.mixin",
  "compatibilityLevel": "JAVA_21",
  "mixins": ["ServerWorldLookupLockMixin"],
  "injectors": {"defaultRequire": 0}
}

In der fabric.mod.json hängt alles zusammen. Unter "mixins" steht der Dateiname der Mixin-Config, unter "entrypoints" die Klasse mit dem ModInitializer, die beim Start die Log-Zeile aus dem übernächsten Abschnitt schreibt. "depends" verlangt Fabric Loader 0.16.0 oder neuer (wegen @WrapMethod) und die Fabric API, deren Methoden das Mixin umhüllt. Mit "environment": "server" läuft die Mod nur auf dem Server, deine Spieler müssen sie nicht installieren. Das Template setzt hier "*", das änderst du. Gekürzt sieht die Datei so aus:

{
  "schemaVersion": 1,
  "id": "servermod",
  "version": "${version}",
  "name": "Server Mod",
  "environment": "server",
  "entrypoints": {
    "main": ["de.example.servermod.ServerMod"]
  },
  "mixins": ["servermod.mixins.json"],
  "depends": {
    "fabricloader": ">=0.16.0",
    "minecraft": "~1.21.1",
    "java": ">=21",
    "fabric-api": "*"
  }
}

Die Mod bauen, ohne Java lokal zu installieren

Für Minecraft 1.21.1 brauchst du Java 21. Gradle ist das Build-Tool, das aus dem Quellcode die fertige Mod-Datei baut. Ein Projekt aus dem Template bringt den Gradle-Wrapper ./gradlew mit, der die passende Gradle-Version selbst herunterlädt. Hast du lokal ein JDK 21 (das Java-Paket zum Entwickeln), reicht im Projektordner ./gradlew build. Ohne lokales Java baust du in einem Docker-Container (eine abgeschottete Umgebung mit Java) mit dem Image eclipse-temurin:21-jdk, der danach wieder verschwindet:

cd /srv/minecraft/mod/deine-servermod
chmod +x gradlew
docker run --rm \
  -u $(id -u):$(id -g) \
  -e GRADLE_USER_HOME=/work/.gradle \
  -v "$PWD":/work -w /work \
  eclipse-temurin:21-jdk \
  ./gradlew build --no-daemon
# fertige Jar liegt danach in build/libs/

Die Mod aus meinem Repo bringt den Wrapper mit, also geht dort ebenfalls ./gradlew build. Ohne lokales JDK nimmst du das offizielle Gradle-Image gradle:8.12-jdk21, so wie auch auf CobbleBox gebaut wurde:

cd minecraft-weekly-world/mod
docker run --rm \
  -u $(id -u):$(id -g) \
  -e GRADLE_USER_HOME=/work/.gradle \
  -v "$PWD":/work -w /work \
  gradle:8.12-jdk21 \
  gradle build --no-daemon -q
# fertige Jar: build/libs/weeklyworld-1.0.0.jar

Mit -u gehören die erzeugten Dateien deinem User und nicht root. GRADLE_USER_HOME im Projektordner sorgt dafür, dass Gradle seinen Cache behält und der zweite Build deutlich schneller ist. Den Ordner .gradle und build solltest du aus deinen Backups ausschließen.

In build/libs/ liegen bei Template-Projekten zwei Dateien, zum Beispiel servermod-1.0.0.jar und servermod-1.0.0-sources.jar. Die -sources-Jar enthält nur den Quellcode und gehört nicht auf den Server. Die andere Jar (die fertige Mod-Datei) kopierst du in den mods-Ordner des Servers, entfernst die alte Version und startest neu:

ls build/libs/
cp build/libs/servermod-1.0.0.jar /srv/minecraft/mods/

Prüfen, ob der Fix wirkt

Weil das Mixin mit require = 0 auch still fehlschlagen kann, schreibt die Mod beim Laden der Overworld eine Zeile ins Log. Der Code unten steht im Entrypoint, zum Beispiel src/main/java/de/example/servermod/ServerMod.java. Er prüft, ob die Welt das Marker-Interface hat, und schreibt dann active oder NOT active ins Log (Yarn-Mappings):

package de.example.servermod;

import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerWorldEvents;
import net.minecraft.world.World;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ServerMod implements ModInitializer {
    public static final Logger LOG = LoggerFactory.getLogger("servermod");

    @Override
    public void onInitialize() {
        ServerWorldEvents.LOAD.register((server, world) -> {
            if (world.getRegistryKey() == World.OVERWORLD) {
                LOG.info("Lookup cache lock: {}",
                    ((Object) world instanceof LookupLocked) ? "active" : "NOT active");
            }
        });
    }
}

Mit diesem Code und mit der Mod aus dem Repo steht nach dem Start Lookup cache lock: active im Log. Die Mod aus dem Repo schreibt Lookup cache lock: disabled in config, wenn du lookupCacheLock auf false gesetzt hast. Auf dem Screenshot unten siehst du die Zeile von CobbleBox. Dort heißt sie Lookup cache lock (crash fix 05.10.): active, weil die Server-Mod den Text um das Datum ergänzt. Entscheidend ist in beiden Fällen das active am Ende. Steht dort NOT active, wurde das Mixin nicht angewendet, etwa weil die Fabric API die Methoden umbenannt hat.

Danach beobachtest du das Log auf Error upgrading chunk und auf neue Watchdog-Meldungen. Auf CobbleBox lief der Fix ab 23:02 Uhr am selben Abend, als Version 1.3.1 der Server-Mod. Über Nacht kam kein Fehler mehr, und die Abenteuerwelt wurde am 6. Oktober wieder geöffnet. Seitdem steht die Prüfung der Log-Zeile nach jedem Neustart mit auf der Checkliste.

Was schiefging: keine fremden Klassen im Mixin-Package

Der erste Build des Fixes, Version 1.3.0, hat den Server-Start für rund 6 Minuten blockiert. Der Grund war ein Strukturfehler im Projekt: Im Mixin-Package (dem Code-Ordner für die Mixins) lag eine Klasse, die selbst kein Mixin ist. Mixin behandelt alles im Package aus "package" der Mixin-Config als Mixin-Klasse. Diese Klassen lässt es nicht normal laden. Wenn dein restlicher Code eine solche Klasse anfasst, zum Beispiel ein Interface für einen instanceof-Check, geht das schief.

Ins Mixin-Package gehören deshalb nur Mixins. Hilfsklassen, Marker-Interfaces und Logik legst du in ein anderes Package, etwa de.example.servermod statt de.example.servermod.mixin. In Version 1.3.1 lag LookupLocked deshalb eine Ebene höher, und der Server startete wieder normal. Teste einen neuen Mixin-Build trotzdem nach Möglichkeit erst auf einem Testserver, bevor du ihn auf den Live-Server legst.

Fazit

Wie die wöchentliche Abenteuerwelt selbst funktioniert, in der der Crash zuerst auftrat, erklärt der Guide Minecraft Welt automatisch neu generieren. Den kompletten Code von Mod und Mixin findest du im Repo mapplebox/minecraft-weekly-world auf GitHub, den Mixin unter mod/src/main/java/de/mapplebox/weeklyworld/mixin/ServerWorldLookupLockMixin.java.

Bei einem Watchdog-Crash suchst du zuerst, worauf der Server-Thread wartet, und dann den Thread, der diese Arbeit erledigen sollte. Taucht dort ein c2me-worker mit fabric_invalidateCache und "this.wrapped" is null auf, hast du sehr wahrscheinlich dieselbe Race Condition. Dann reicht ein Mixin mit einem Lock pro Welt als Fix. Wie du einen Cobblemon-Server mit COBBLEVERSE und Pelican (einem Web-Panel für Gameserver) überhaupt aufsetzt, liest du im Guide Cobblemon Server erstellen. Wenn du sehen willst, wie das Ganze im Betrieb läuft, schau auf CobbleBox vorbei: 2mc.mapplebox.de.

Guide teilen

Hat dir der Guide geholfen? Dann spendier mir gern einen Kaffee auf Ko-fi. Damit bleibt MappleBox werbefrei.

Ähnliche Beiträge