From fa011b1017ef4c6a838365380d3825e01c20a878 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:47:13 -0600 Subject: [PATCH] Preserve active Velocity YAML configuration on failed reload --- .../file/velocity/VelocityYMLFile.java | 31 ++++++++++--- .../tests/file/VelocityYMLFileTest.java | 45 +++++++++++++++++++ docs/shared-libraries.md | 10 +++++ 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/velocity/VelocityYMLFile.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/velocity/VelocityYMLFile.java index a9852cb5..f30174fb 100644 --- a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/velocity/VelocityYMLFile.java +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/velocity/VelocityYMLFile.java @@ -1,7 +1,10 @@ package com.bencodez.simpleapi.file.velocity; +import java.io.BufferedReader; import java.io.File; import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -19,7 +22,7 @@ public class VelocityYMLFile { @Getter @Setter - private ConfigurationNode conf; + private volatile ConfigurationNode conf; @Getter private final File file; @@ -31,7 +34,7 @@ public VelocityYMLFile(File file) { ensureFileExists(file); this.loader = buildLoader(file.toPath()); - this.conf = loadOrEmpty(); + this.conf = loadInitialOrEmpty(); } private static void ensureFileExists(File file) { @@ -55,7 +58,7 @@ private static YamlConfigurationLoader buildLoader(Path path) { .build(); } - private ConfigurationNode loadOrEmpty() { + private ConfigurationNode loadInitialOrEmpty() { try { return loader.load(); } catch (IOException e) { @@ -110,9 +113,25 @@ public List getStringList(ConfigurationNode node, ArrayList def) } } - public void reload() { - this.loader = buildLoader(file.toPath()); - this.conf = loadOrEmpty(); + /** + * Publishes a replacement only after a successful load. Startup may use + * defaults, but a reload failure must keep the last valid active snapshot. + * @throws UncheckedIOException when the replacement cannot be read or parsed + */ + public synchronized void reload() { + YamlConfigurationLoader replacement = buildLoader(file.toPath()); + try { + ConfigurationNode loaded; + // Open before invoking Configurate: its path loader treats a missing file as empty. + try (BufferedReader reader = Files.newBufferedReader(file.toPath())) { + loaded = YamlConfigurationLoader.builder().source(() -> reader) + .nodeStyle(NodeStyle.BLOCK).build().load(); + } + this.loader = replacement; + this.conf = loaded; + } catch (IOException failure) { + throw new UncheckedIOException("Velocity YAML reload failed; previous configuration remains active", failure); + } } public void save() { diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/file/VelocityYMLFileTest.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/file/VelocityYMLFileTest.java index 318e5d23..2284a5d8 100644 --- a/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/file/VelocityYMLFileTest.java +++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/file/VelocityYMLFileTest.java @@ -41,6 +41,51 @@ public void tearDown() throws IOException { Files.deleteIfExists(Paths.get(TEST_FILE_PATH)); } + @Test + void malformedReloadPreservesActiveSnapshotAndDiskThenRecovers() throws Exception { + Files.writeString(testFile.toPath(), "BungeeMethod: MQTT\nCustom:\n label: administrator\n"); + VelocityYMLFile config = new VelocityYMLFile(testFile); + ConfigurationNode active = config.getData(); + String malformed = "BungeeMethod: [broken\n"; + Files.writeString(testFile.toPath(), malformed); + org.junit.jupiter.api.Assertions.assertThrows(java.io.UncheckedIOException.class, config::reload); + org.junit.jupiter.api.Assertions.assertSame(active, config.getData()); + assertEquals("MQTT", config.getNode("BungeeMethod").getString()); + assertEquals("administrator", config.getNode("Custom", "label").getString()); + assertEquals(malformed, Files.readString(testFile.toPath())); + Files.writeString(testFile.toPath(), "BungeeMethod: REDIS\nCustom:\n label: corrected\n"); + config.reload(); + assertEquals("REDIS", config.getNode("BungeeMethod").getString()); + assertEquals("corrected", config.getNode("Custom", "label").getString()); + } + + @Test + void missingOrUnreadableReloadDoesNotPublishStartupDefaults() throws Exception { + Files.writeString(testFile.toPath(), "BungeeMethod: MQTT\n"); + VelocityYMLFile config = new VelocityYMLFile(testFile); + ConfigurationNode active = config.getData(); + Files.delete(testFile.toPath()); + org.junit.jupiter.api.Assertions.assertThrows(java.io.UncheckedIOException.class, config::reload); + org.junit.jupiter.api.Assertions.assertSame(active, config.getData()); + Files.createDirectory(testFile.toPath()); + org.junit.jupiter.api.Assertions.assertThrows(java.io.UncheckedIOException.class, config::reload); + org.junit.jupiter.api.Assertions.assertSame(active, config.getData()); + } + + @Test + void missingStartupFileAndValidEmptyReloadKeepDefaultSemantics() throws Exception { + Files.delete(testFile.toPath()); + VelocityYMLFile config = new VelocityYMLFile(testFile); + assertTrue(testFile.isFile()); + assertEquals("default", config.getNode("missing").getString("default")); + Files.writeString(testFile.toPath(), "BungeeMethod: MQTT\n"); + config.reload(); + assertEquals("MQTT", config.getNode("BungeeMethod").getString()); + Files.writeString(testFile.toPath(), ""); + config.reload(); + assertEquals("PLUGINMESSAGING", config.getNode("BungeeMethod").getString("PLUGINMESSAGING")); + } + @Test public void getBooleanReturnsDefaultWhenNodeDoesNotExist() { VelocityYMLFile velocityYMLFile = new VelocityYMLFile(testFile); diff --git a/docs/shared-libraries.md b/docs/shared-libraries.md index b5e90284..b58f540a 100644 --- a/docs/shared-libraries.md +++ b/docs/shared-libraries.md @@ -192,3 +192,13 @@ The deterministic lifecycle tests run in the normal Maven suite. To additionally exercise real bidirectional restoration after an ephemeral loopback broker restart, pass `-Dsimpleapi.mqtt.broker=/path/to/mosquitto`. This test never calls manual reconnect or re-registers subscriptions after the restart. + +### Legacy Velocity YAML reload + +`VelocityYMLFile` retains its existing startup behavior (including creation of a +missing initial file and defaults when initial loading fails). Normal `reload()` +is different: it reads a replacement before publishing it and throws +`UncheckedIOException` on unreadable, missing, or malformed input, keeping the +last valid active configuration. The failed reload does not rewrite the file. +Callers must abort runtime reconfiguration when reload fails. An intentionally +empty, valid YAML document remains accepted.