Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.bencodez.votingplugin.proxy.velocity;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.channels.Channels;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.Files;
Expand Down Expand Up @@ -203,9 +205,23 @@ public synchronized void verifyControlProxyRoutingSnapshot(byte[] snapshot) thro
}
}

/** Normal reload must never publish defaults after a failed replacement load. */
@Override
public synchronized void reload() {
try {
loadControlConfiguration();
} catch (IOException failure) {
throw new UncheckedIOException("Velocity configuration reload failed; previous configuration remains active", failure);
}
}

/** Loads the active file without the superclass's empty-config fallback. */
public synchronized void loadControlConfiguration() throws IOException {
ConfigurationNode loaded = YamlConfigurationLoader.builder().path(configurationFile.toPath()).build().load();
ConfigurationNode loaded;
// Open first so Configurate cannot substitute an empty root for a missing file.
try (BufferedReader reader = Files.newBufferedReader(configurationFile.toPath())) {
loaded = YamlConfigurationLoader.builder().source(() -> reader).build().load();
}
setConf(loaded);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -699,15 +699,17 @@ public boolean isReloading() {
*/
public void reloadAllInternal(boolean loadMysql) {
synchronized (reloadLock) {
final boolean retainedRuntimeWasOperational = runtimeOperational;
reloading = true;
cancelTasks();

// Reject an invalid replacement before fencing votes or retiring runtime services.
try {
config.reload();
} catch (Exception e) {
logger.error("Failed to reload bungeeconfig.yml", e);
// Parser exceptions may contain administrator secrets from the source YAML.
logger.error("Failed to reload bungeeconfig.yml; the previous configuration and runtime remain active.");
return;
}
final boolean retainedRuntimeWasOperational = runtimeOperational;
reloading = true;
cancelTasks();

try {
ChannelIdentifier old = channel;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.bencodez.votingplugin.proxy.velocity;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

import org.bstats.velocity.Metrics;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.slf4j.Logger;

import com.bencodez.votingplugin.proxy.VotingPluginProxy;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.scheduler.ScheduledTask;

class VelocityConfigReloadTest {
@TempDir Path directory;

@Test void malformedNormalReloadPreservesMqttAndAdministratorSettings() throws Exception {
Path file = validFile();
VelocityConfig config = new VelocityConfig(file.toFile());
var active = config.getData();
String malformed = "BungeeMethod: [broken\n";
Files.writeString(file, malformed);
assertThrows(java.io.UncheckedIOException.class, config::reload);
assertSame(active, config.getData());
assertEquals("MQTT", config.getBungeeMethod());
assertEquals("administrator", config.getNode("Custom", "Label").getString());
assertEquals(malformed, Files.readString(file));
Files.writeString(file, "BungeeMethod: REDIS\nCustom:\n Label: repaired\n");
config.reload();
assertEquals("REDIS", config.getBungeeMethod());
assertEquals("repaired", config.getNode("Custom", "Label").getString());
}

@Test void strictControlReloadStillRejectsWithoutReplacingSnapshot() throws Exception {
Path file = validFile();
VelocityConfig config = new VelocityConfig(file.toFile());
var active = config.getData();
Files.writeString(file, "BungeeMethod: [broken\n");
assertThrows(IOException.class, config::loadControlConfiguration);
assertSame(active, config.getData());
assertEquals("MQTT", config.getBungeeMethod());
}

@Test void missingReloadPreservesSnapshotButMissingStartupUsesDefaults() throws Exception {
Path file = validFile();
VelocityConfig config = new VelocityConfig(file.toFile());
var active = config.getData();
Files.delete(file);
assertThrows(java.io.UncheckedIOException.class, config::reload);
assertSame(active, config.getData());
assertFalse(Files.exists(file));
VelocityConfig firstStartup = new VelocityConfig(file.toFile());
assertEquals("PLUGINMESSAGING", firstStartup.getBungeeMethod());
assertTrue(Files.exists(file));
}

@Test void malformedRuntimeReloadAbortsBeforeTasksChannelsOrTransportChange() throws Exception {
for (boolean full : new boolean[] {false, true}) {
Path file = validFile();
VelocityConfig config = new VelocityConfig(file.toFile());
var active = config.getData();
ProxyServer server = mock(ProxyServer.class);
Logger logger = mock(Logger.class);
VotingPluginVelocity plugin = new VotingPluginVelocity(server, logger,
mock(Metrics.Factory.class), directory);
VotingPluginProxy runtime = mock(VotingPluginProxy.class);
ScheduledTask voteCheck = mock(ScheduledTask.class);
ScheduledTask cacheSave = mock(ScheduledTask.class);
setField(plugin, "config", config);
setField(plugin, "votingPluginProxy", runtime);
setField(plugin, "runtimeOperational", true);
setField(plugin, "voteCheckTask", voteCheck);
setField(plugin, "cacheSaveTask", cacheSave);
String malformed = "BungeeMethod: [broken\n";
Files.writeString(file, malformed);
try {
plugin.reloadAllInternal(full);
assertSame(active, config.getData());
assertEquals("MQTT", config.getBungeeMethod());
assertTrue(plugin.isRuntimeOperational());
assertFalse(plugin.isReloading());
assertSame(runtime, plugin.getVotingPluginProxy());
verifyNoInteractions(runtime, voteCheck, cacheSave, server);
verify(logger).error("Failed to reload bungeeconfig.yml; the previous configuration and runtime remain active.");
assertEquals(malformed, Files.readString(file));
} finally { plugin.getTimer().shutdownNow(); }
}
}

private Path validFile() throws IOException {
Path file = directory.resolve("bungeeconfig.yml");
Files.writeString(file, "BungeeMethod: MQTT\nCustom:\n Label: administrator\n");
return file;
}

private static void setField(Object target, String name, Object value) throws Exception {
var field = VotingPluginVelocity.class.getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
}
9 changes: 9 additions & 0 deletions docs/proxy-vote-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,12 @@ HTTP retains its existing request recovery and also uses the completion outbox
after capability discovery. The generic outbox covers plugin messaging, Redis,
MQTT, MySQL, sockets, and HTTP. No reward data model or Bukkit vote ordering
changes.

## Velocity configuration reload failures

Normal Velocity soft/full reload validates `bungeeconfig.yml` before changing
channels, cancelling tasks, or replacing the proxy runtime. If the file is
missing, unreadable, or malformed, reload logs a failure and keeps the previous
active configuration and runtime. It does not rewrite the invalid file or
substitute default transport settings. Repair the file and reload again.
This preservation contract is distinct from initial startup default handling.
Loading