Repository: neoforged/NeoForge
Pull request #1439: Sync server config changes to the connected clients
Author: login: Technici4n
Labels: name: enhancement
description: New (or improvement to existing) feature or request
name: area: networking
description: Related to networking
name: 1.21.1
description: Targeted at Minecraft 1.21.1
Created: 2024-08-10T19:57:44Z | Merged: 2025-04-20T13:03:36Z

What the title says. All changes to server configs (because of the file watcher or a direct `.set/.save` call) are now synced to connected clients. This is done by listening to `ModConfigEvent.Reloading` for all mod busses, and sending the packet accordingly. The tracking is a bit complex due to threading concerns.


Diff:
diff --git a/src/main/java/net/neoforged/neoforge/common/NeoForgeEventHandler.java b/src/main/java/net/neoforged/neoforge/common/NeoForgeEventHandler.java
index de6f12b7743..29528261b62 100644
--- a/src/main/java/net/neoforged/neoforge/common/NeoForgeEventHandler.java
+++ b/src/main/java/net/neoforged/neoforge/common/NeoForgeEventHandler.java
@@ -36,6 +36,7 @@
 import net.neoforged.neoforge.event.level.LevelEvent;
 import net.neoforged.neoforge.event.tick.ServerTickEvent;
 import net.neoforged.neoforge.internal.NeoForgeProxy;
+import net.neoforged.neoforge.network.ConfigSync;
 import net.neoforged.neoforge.network.PacketDistributor;
 import net.neoforged.neoforge.network.payload.RegistryDataMapSyncPayload;
 import net.neoforged.neoforge.registries.DataMapLoader;
@@ -83,6 +84,7 @@ public void preServerTick(ServerTickEvent.Pre event) {
     @SubscribeEvent
     public void postServerTick(ServerTickEvent.Post event) {
         WorldWorkerManager.tick(false);
+        ConfigSync.syncPendingConfigs(event.getServer());
     }
 
     @SubscribeEvent
diff --git a/src/main/java/net/neoforged/neoforge/common/NeoForgeMod.java b/src/main/java/net/neoforged/neoforge/common/NeoForgeMod.java
index cf81f799289..e3b5e217d63 100644
--- a/src/main/java/net/neoforged/neoforge/common/NeoForgeMod.java
+++ b/src/main/java/net/neoforged/neoforge/common/NeoForgeMod.java
@@ -138,6 +138,7 @@
 import net.neoforged.neoforge.fluids.crafting.display.FluidTagSlotDisplay;
 import net.neoforged.neoforge.forge.snapshots.ForgeSnapshotsMod;
 import net.neoforged.neoforge.internal.versions.neoforge.NeoForgeVersion;
+import net.neoforged.neoforge.network.ConfigSync;
 import net.neoforged.neoforge.network.DualStackUtils;
 import net.neoforged.neoforge.registries.DataPackRegistryEvent;
 import net.neoforged.neoforge.registries.DeferredHolder;
@@ -566,6 +567,7 @@ public NeoForgeMod(IEventBus modEventBus, Dist dist, ModContainer container) {
         CONDITION_CODECS.register(modEventBus);
         GLOBAL_LOOT_MODIFIER_SERIALIZERS.register(modEventBus);
         NeoForge.EVENT_BUS.addListener(this::serverStopping);
+        ConfigSync.registerEventListeners();
         container.registerConfig(ModConfig.Type.SERVER, NeoForgeServerConfig.SPEC);
         container.registerConfig(ModConfig.Type.COMMON, NeoForgeCommonConfig.SPEC);
         NeoForgeRegistriesSetup.setup(modEventBus);
diff --git a/src/main/java/net/neoforged/neoforge/network/ConfigSync.java b/src/main/java/net/neoforged/neoforge/network/ConfigSync.java
index adc60d019c4..13283b7ca5e 100644
--- a/src/main/java/net/neoforged/neoforge/network/ConfigSync.java
+++ b/src/main/java/net/neoforged/neoforge/network/ConfigSync.java
@@ -6,22 +6,47 @@
 package net.neoforged.neoforge.network;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
-import java.util.List;
+import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Optional;
+import java.util.WeakHashMap;
 import java.util.stream.Collectors;
+import net.minecraft.network.Connection;
+import net.minecraft.network.protocol.configuration.ServerConfigurationPacketListener;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.level.ServerPlayer;
+import net.neoforged.fml.ModList;
 import net.neoforged.fml.config.ConfigTracker;
 import net.neoforged.fml.config.ModConfig;
 import net.neoforged.fml.config.ModConfigs;
+import net.neoforged.fml.event.config.ModConfigEvent;
+import net.neoforged.neoforge.network.configuration.SyncConfig;
 import net.neoforged.neoforge.network.payload.ConfigFilePayload;
 import org.jetbrains.annotations.ApiStatus;
 
 @ApiStatus.Internal
 public final class ConfigSync {
+    private static final Object lock = new Object();
+    /**
+     * Connection -> Config file name -> byte[] of the config serialized to TOML.
+     *
+     * <p>Pending config updates get sent to players in the PLAY phase only,
+     * but start being tracked as soon as the {@link SyncConfig} configuration task runs.
+     * This ensures that all updates during the configuration phase will eventually arrive to the clients.
+     *
+     * <p>Connections get removed when GC'ed thanks to the WeakHashMap.
+     */
+    private static final Map<Connection, Map<String, byte[]>> configsToSync = new WeakHashMap<>();
+
     private ConfigSync() {}
 
-    public static List<ConfigFilePayload> syncConfigs() {
+    public static void syncAllConfigs(ServerConfigurationPacketListener listener) {
+        synchronized (lock) {
+            configsToSync.put(listener.getConnection(), new LinkedHashMap<>());
+        }
+
         final Map<String, byte[]> configData = ModConfigs.getConfigSet(ModConfig.Type.SERVER).stream().collect(Collectors.toMap(ModConfig::getFileName, mc -> {
             try {
                 return Files.readAllBytes(mc.getFullPath());
@@ -30,9 +55,60 @@ public static List<ConfigFilePayload> syncConfigs() {
             }
         }));
 
-        return configData.entrySet().stream()
-                .map(e -> new ConfigFilePayload(e.getKey(), e.getValue()))
-                .toList();
+        for (var entry : configData.entrySet()) {
+            listener.send(new ConfigFilePayload(entry.getKey(), entry.getValue()));
+        }
+    }
+
+    /**
+     * Registers a listener for {@link ModConfigEvent.Reloading} for all mod busses,
+     * that will sync changes to server configs to connected clients.
+     */
+    public static void registerEventListeners() {
+        for (var modContainer : ModList.get().getSortedMods()) {
+            var modBus = modContainer.getEventBus();
+            if (modBus != null) {
+                modBus.addListener(ModConfigEvent.Reloading.class, event -> {
+                    var config = event.getConfig();
+                    if (config.getType() != ModConfig.Type.SERVER) {
+                        return;
+                    }
+                    var loadedConfig = config.getLoadedConfig();
+                    if (loadedConfig == null) {
+                        return;
+                    }
+
+                    // Write config bytes and queue for syncing to all connected players.
+                    // This is harmless client-side, as the configsToSync map is either empty or full of stale connections.
+                    var bytes = loadedConfig.config().configFormat().createWriter().writeToString(loadedConfig.config()).getBytes(StandardCharsets.UTF_8);
+                    synchronized (lock) {
+                        for (var toSync : configsToSync.values()) {
+                            toSync.put(config.getFileName(), bytes);
+                        }
+                    }
+                });
+            }
+        }
+    }
+
+    public static void syncPendingConfigs(MinecraftServer server) {
+        synchronized (lock) {
+            for (var player : server.getPlayerList().getPlayers()) {
+                var toSync = configsToSync.get(player.connection.getConnection());
+                if (toSync == null) {
+                    // null for GameTestPlayer. Should not happen for normal players though.
+                    if (player.getClass() == ServerPlayer.class) {
+                        throw new IllegalStateException("configsToSync should contain an entry for player " + player.getName());
+                    } else {
+                        continue;
+                    }
+                }
+                toSync.forEach((fileName, data) -> {
+                    PacketDistributor.sendToPlayer(player, new ConfigFilePayload(fileName, data));
+                });
+                toSync.clear();
+            }
+        }
     }
 
     public static void receiveSyncedConfig(final byte[] contents, final String fileName) {
diff --git a/src/main/java/net/neoforged/neoforge/network/NetworkInitialization.java b/src/main/java/net/neoforged/neoforge/network/NetworkInitialization.java
index 869597efb81..a3ef8557f8d 100644
--- a/src/main/java/net/neoforged/neoforge/network/NetworkInitialization.java
+++ b/src/main/java/net/neoforged/neoforge/network/NetworkInitialization.java
@@ -49,7 +49,7 @@ private static void register(final RegisterPayloadHandlersEvent event) {
         final PayloadRegistrar registrar = event.registrar("1") // Update this version if the payload semantics change.
                 .optional();
         registrar
-                .configurationToClient(
+                .commonToClient(
                         ConfigFilePayload.TYPE,
                         ConfigFilePayload.STREAM_CODEC,
                         clientHandler())
diff --git a/src/main/java/net/neoforged/neoforge/network/configuration/SyncConfig.java b/src/main/java/net/neoforged/neoforge/network/configuration/SyncConfig.java
index 9dbd7f8f7a1..ab89dadc94d 100644
--- a/src/main/java/net/neoforged/neoforge/network/configuration/SyncConfig.java
+++ b/src/main/java/net/neoforged/neoforge/network/configuration/SyncConfig.java
@@ -25,7 +25,7 @@ public record SyncConfig(ServerConfigurationPacketListener listener) implements
 
     @Override
     public void run(Consumer<CustomPacketPayload> sender) {
-        ConfigSync.syncConfigs().forEach(sender);
+        ConfigSync.syncAllConfigs(listener);
         listener().finishCurrentTask(type());
     }
 


Reviews:
user: login: marchermans
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-19T19:31:20Z
user: login: shartte
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-19T19:36:37Z
user: login: embeddedt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T19:39:47Z
user: login: shartte
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-19T19:54:47Z
user: login: embeddedt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T19:56:03Z
user: login: Technici4n
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T22:05:13Z
user: login: Technici4n
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-20T09:02:09Z
user: login: shartte
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-20T09:58:34Z

Review comments:
diff_hunk: @@ -30,9 +54,55 @@ public static List<ConfigFilePayload> syncConfigs() {
             }
         }));
 
-        return configData.entrySet().stream()
-                .map(e -> new ConfigFilePayload(e.getKey(), e.getValue()))
-                .toList();
+        for (var entry : configData.entrySet()) {
+            listener.send(new ConfigFilePayload(entry.getKey(), entry.getValue()));
+        }
+    }
+
+    /**
+     * Registers a listener for {@link ModConfigEvent.Reloading} for all mod busses,
+     * that will sync changes to server configs to connected clients.
+     */
+    public static void registerEventListeners() {
+        for (var modContainer : ModList.get().getSortedMods()) {
+            var modBus = modContainer.getEventBus();
+            if (modBus != null) {
+                modBus.addListener(ModConfigEvent.Reloading.class, event -> {
+                    var config = event.getConfig();
+                    if (config.getType() != ModConfig.Type.SERVER) {
+                        return;
+                    }
+                    var loadedConfig = config.getLoadedConfig();
+                    if (loadedConfig == null) {
+                        return;
+                    }
+
+                    // Write config bytes and queue for syncing to all connected players.
+                    // This is harmless client-side, as the configsToSync map is either empty or full of stale connections.
+                    var bytes = loadedConfig.config().configFormat().createWriter().writeToString(loadedConfig.config()).getBytes(StandardCharsets.UTF_8);
+                    synchronized (lock) {
+                        for (var toSync : configsToSync.values()) {
+                            toSync.put(config.getFileName(), bytes);
+                        }
+                    }
+                });
+            }
+        }
+    }
+
+    public static void syncPendingConfigs(MinecraftServer server) {
+        synchronized (lock) {
+            for (var player : server.getPlayerList().getPlayers()) {
+                var toSync = configsToSync.get(player.connection.getConnection());
path: src/main/java/net/neoforged/neoforge/network/ConfigSync.java
user: login: embeddedt
body: It's probably not an issue on the average modded server but I'm not entirely pleased that we iterate the entire player list on every tick and read from the WeakHashMap even when no configs need to be synced (which is the most common scenario).
created_at: 2025-04-19T19:39:46Z
author_association: MEMBER
diff_hunk: @@ -30,9 +54,55 @@ public static List<ConfigFilePayload> syncConfigs() {
             }
         }));
 
-        return configData.entrySet().stream()
-                .map(e -> new ConfigFilePayload(e.getKey(), e.getValue()))
-                .toList();
+        for (var entry : configData.entrySet()) {
+            listener.send(new ConfigFilePayload(entry.getKey(), entry.getValue()));
+        }
+    }
+
+    /**
+     * Registers a listener for {@link ModConfigEvent.Reloading} for all mod busses,
+     * that will sync changes to server configs to connected clients.
+     */
+    public static void registerEventListeners() {
+        for (var modContainer : ModList.get().getSortedMods()) {
+            var modBus = modContainer.getEventBus();
+            if (modBus != null) {
+                modBus.addListener(ModConfigEvent.Reloading.class, event -> {
+                    var config = event.getConfig();
+                    if (config.getType() != ModConfig.Type.SERVER) {
+                        return;
+                    }
+                    var loadedConfig = config.getLoadedConfig();
+                    if (loadedConfig == null) {
+                        return;
+                    }
+
+                    // Write config bytes and queue for syncing to all connected players.
+                    // This is harmless client-side, as the configsToSync map is either empty or full of stale connections.
+                    var bytes = loadedConfig.config().configFormat().createWriter().writeToString(loadedConfig.config()).getBytes(StandardCharsets.UTF_8);
+                    synchronized (lock) {
+                        for (var toSync : configsToSync.values()) {
+                            toSync.put(config.getFileName(), bytes);
+                        }
+                    }
+                });
+            }
+        }
+    }
+
+    public static void syncPendingConfigs(MinecraftServer server) {
+        synchronized (lock) {
+            for (var player : server.getPlayerList().getPlayers()) {
+                var toSync = configsToSync.get(player.connection.getConnection());
path: src/main/java/net/neoforged/neoforge/network/ConfigSync.java
user: login: shartte
body: I can see a `if (configsToSync.isEmpty()) return;` being enough here.
created_at: 2025-04-19T19:54:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -30,9 +54,55 @@ public static List<ConfigFilePayload> syncConfigs() {
             }
         }));
 
-        return configData.entrySet().stream()
-                .map(e -> new ConfigFilePayload(e.getKey(), e.getValue()))
-                .toList();
+        for (var entry : configData.entrySet()) {
+            listener.send(new ConfigFilePayload(entry.getKey(), entry.getValue()));
+        }
+    }
+
+    /**
+     * Registers a listener for {@link ModConfigEvent.Reloading} for all mod busses,
+     * that will sync changes to server configs to connected clients.
+     */
+    public static void registerEventListeners() {
+        for (var modContainer : ModList.get().getSortedMods()) {
+            var modBus = modContainer.getEventBus();
+            if (modBus != null) {
+                modBus.addListener(ModConfigEvent.Reloading.class, event -> {
+                    var config = event.getConfig();
+                    if (config.getType() != ModConfig.Type.SERVER) {
+                        return;
+                    }
+                    var loadedConfig = config.getLoadedConfig();
+                    if (loadedConfig == null) {
+                        return;
+                    }
+
+                    // Write config bytes and queue for syncing to all connected players.
+                    // This is harmless client-side, as the configsToSync map is either empty or full of stale connections.
+                    var bytes = loadedConfig.config().configFormat().createWriter().writeToString(loadedConfig.config()).getBytes(StandardCharsets.UTF_8);
+                    synchronized (lock) {
+                        for (var toSync : configsToSync.values()) {
+                            toSync.put(config.getFileName(), bytes);
+                        }
+                    }
+                });
+            }
+        }
+    }
+
+    public static void syncPendingConfigs(MinecraftServer server) {
+        synchronized (lock) {
+            for (var player : server.getPlayerList().getPlayers()) {
+                var toSync = configsToSync.get(player.connection.getConnection());
path: src/main/java/net/neoforged/neoforge/network/ConfigSync.java
user: login: embeddedt
body: It won't be, the `configsToSync` map always has an entry for any active connection (because the entry is populated in `syncAllConfigs` and kept until GC clears it).
created_at: 2025-04-19T19:56:03Z
author_association: MEMBER
diff_hunk: @@ -30,9 +54,55 @@ public static List<ConfigFilePayload> syncConfigs() {
             }
         }));
 
-        return configData.entrySet().stream()
-                .map(e -> new ConfigFilePayload(e.getKey(), e.getValue()))
-                .toList();
+        for (var entry : configData.entrySet()) {
+            listener.send(new ConfigFilePayload(entry.getKey(), entry.getValue()));
+        }
+    }
+
+    /**
+     * Registers a listener for {@link ModConfigEvent.Reloading} for all mod busses,
+     * that will sync changes to server configs to connected clients.
+     */
+    public static void registerEventListeners() {
+        for (var modContainer : ModList.get().getSortedMods()) {
+            var modBus = modContainer.getEventBus();
+            if (modBus != null) {
+                modBus.addListener(ModConfigEvent.Reloading.class, event -> {
+                    var config = event.getConfig();
+                    if (config.getType() != ModConfig.Type.SERVER) {
+                        return;
+                    }
+                    var loadedConfig = config.getLoadedConfig();
+                    if (loadedConfig == null) {
+                        return;
+                    }
+
+                    // Write config bytes and queue for syncing to all connected players.
+                    // This is harmless client-side, as the configsToSync map is either empty or full of stale connections.
+                    var bytes = loadedConfig.config().configFormat().createWriter().writeToString(loadedConfig.config()).getBytes(StandardCharsets.UTF_8);
+                    synchronized (lock) {
+                        for (var toSync : configsToSync.values()) {
+                            toSync.put(config.getFileName(), bytes);
+                        }
+                    }
+                });
+            }
+        }
+    }
+
+    public static void syncPendingConfigs(MinecraftServer server) {
+        synchronized (lock) {
+            for (var player : server.getPlayerList().getPlayers()) {
+                var toSync = configsToSync.get(player.connection.getConnection());
path: src/main/java/net/neoforged/neoforge/network/ConfigSync.java
user: login: Technici4n
body: I say premature optimization. Iterating a small map is completely fine.
created_at: 2025-04-19T22:05:13Z
author_association: MEMBER
diff_hunk: @@ -30,9 +54,55 @@ public static List<ConfigFilePayload> syncConfigs() {
             }
         }));
 
-        return configData.entrySet().stream()
-                .map(e -> new ConfigFilePayload(e.getKey(), e.getValue()))
-                .toList();
+        for (var entry : configData.entrySet()) {
+            listener.send(new ConfigFilePayload(entry.getKey(), entry.getValue()));
+        }
+    }
+
+    /**
+     * Registers a listener for {@link ModConfigEvent.Reloading} for all mod busses,
+     * that will sync changes to server configs to connected clients.
+     */
+    public static void registerEventListeners() {
+        for (var modContainer : ModList.get().getSortedMods()) {
+            var modBus = modContainer.getEventBus();
+            if (modBus != null) {
+                modBus.addListener(ModConfigEvent.Reloading.class, event -> {
+                    var config = event.getConfig();
+                    if (config.getType() != ModConfig.Type.SERVER) {
+                        return;
+                    }
+                    var loadedConfig = config.getLoadedConfig();
+                    if (loadedConfig == null) {
+                        return;
+                    }
+
+                    // Write config bytes and queue for syncing to all connected players.
+                    // This is harmless client-side, as the configsToSync map is either empty or full of stale connections.
+                    var bytes = loadedConfig.config().configFormat().createWriter().writeToString(loadedConfig.config()).getBytes(StandardCharsets.UTF_8);
+                    synchronized (lock) {
+                        for (var toSync : configsToSync.values()) {
+                            toSync.put(config.getFileName(), bytes);
+                        }
+                    }
+                });
+            }
+        }
+    }
+
+    public static void syncPendingConfigs(MinecraftServer server) {
+        synchronized (lock) {
+            for (var player : server.getPlayerList().getPlayers()) {
+                var toSync = configsToSync.get(player.connection.getConnection());
path: src/main/java/net/neoforged/neoforge/network/ConfigSync.java
user: login: Technici4n
body: I thought a bit about having a boolean indicating whether any change might still be pending, but one has to be careful. `PlayerList#getPlayers()` does not include players in the configuration phase, but those should still receive config changes eventually. For now I would keep what I have.
created_at: 2025-04-20T09:02:09Z
author_association: MEMBER

PR comments:
user: login: neoforged-pr-publishing[bot]
created_at: 2024-08-10T19:58:00Z
body: 
- [x] Publish PR to GitHub Packages

Last commit published: [334350526434db43dff0ada050426049ac0e3769](https://github.com/neoforged/NeoForge/commit/334350526434db43dff0ada050426049ac0e3769).

<details>

<summary>PR Publishing</summary>

### The artifacts published by this PR:  
- :package: [`net.neoforged:neoforge:21.5.50-beta-pr-1439-sync-server-configs`](https://github.com/neoforged/NeoForge/packages/2477490?version=21.5.50-beta-pr-1439-sync-server-configs)
- :package: [`net.neoforged:testframework:21.5.50-beta-pr-1439-sync-server-configs`](https://github.com/neoforged/NeoForge/packages/2477492?version=21.5.50-beta-pr-1439-sync-server-configs)  

### Repository Declaration
In order to use the artifacts published by the PR, add the following repository to your buildscript:
```gradle
repositories {
    maven {
        name 'Maven for PR #1439' // https://github.com/neoforged/NeoForge/pull/1439
        url 'https://prmaven.neoforged.net/NeoForge/pr1439'
        content {
            includeModule('net.neoforged', 'neoforge')
            includeModule('net.neoforged', 'testframework')
        }
    }
}
```
### MDK installation
In order to setup a MDK using the latest PR version, run the following commands in a terminal.  
The script works on both *nix and Windows as long as you have the JDK `bin` folder on the path.  
The script will clone the MDK in a folder named `NeoForge-pr1439`.  
On Powershell you will need to remove the `-L` flag from the `curl` invocation.
```sh
mkdir NeoForge-pr1439
cd NeoForge-pr1439
curl -L https://prmaven.neoforged.net/NeoForge/pr1439/net/neoforged/neoforge/21.5.50-beta-pr-1439-sync-server-configs/mdk-pr1439.zip -o mdk.zip
jar xf mdk.zip
rm mdk.zip || del mdk.zip
```

To test a production environment, you can download the installer from [here](https://prmaven.neoforged.net/NeoForge/pr1439/net.neoforged/neoforge/21.5.50-beta-pr-1439-sync-server-configs/neoforge-21.5.50-beta-pr-1439-sync-server-configs-installer.jar).

</details>
author_association: NONE
user: login: neoforged-automation[bot]
created_at: 2024-10-17T17:42:17Z
body: @Technici4n, this pull request has conflicts, please resolve them for this PR to move forward.
author_association: CONTRIBUTOR
user: login: Matyrobbrt
created_at: 2025-04-19T10:09:36Z
body: Given the lack of activity and the need for a rebase, I'll be closing this PR for now. Feel free to open it again after the conflicts are resolved.
author_association: MEMBER
user: login: Technici4n
created_at: 2025-04-19T18:02:05Z
body: After discussion we have agreed that this PR can be thought of as fixing an important bug, and should therefore be reopened and prioritized.
author_association: MEMBER
user: login: neoforged-releases[bot]
created_at: 2025-04-20T13:08:29Z
body: 🚀 This PR has been released as NeoForge version `21.5.48-beta`.
author_association: NONE