Repository navigation
ConfigWatcher
Basti edited this page Sep 27, 2026
·
2 revisions
ConfigWatcher keeps config.yml (or any file) in sync without manual polling. It watches disk changes asynchronously and then switches back to the Bukkit main thread so your reload logic stays safe.
- Server owners tweak configs directly on disk and expect auto-merges.
- You want lightweight polling (timestamps + CRC32) without blocking the main thread.
- A config flag should enable/disable the watcher live.
- Async change detection by size and CRC32 content hash; the timestamp only decides when to re-hash, so re-saving a file with identical content never triggers the callback.
- A deleted or emptied file never triggers
onChange, so the plugin keeps its current settings.onSkippedreports it once per state (e.g. to log a translated warning), and a file that returns with the same content as before causes no reload. - Main-thread callbacks keep Bukkit APIs safe while the watcher runs off-thread.
- Live enable/disable switches via
setEnabled(boolean), plus manualrefreshBaseline()control. -
describeYamlError(File)checks the file before a reload, so a typo doesn't silently turn every setting back to its default.
- One file, polled: it checks one file per interval. There are no file-system events, folders or patterns; pointed at a folder, it logs hash errors and misses changes. Lang files need one watcher each.
-
What counts as a change: existence, size and a CRC32 of the content, re-hashed only when the size or modification time changed. An edit that keeps both (e.g.
cp -p,rsync -t, unpacking an archive, file systems with coarse timestamps) is missed. - Timing: a change is seen up to one interval late, several saves within one interval give one callback, and a poll can catch a file halfway through saving and fire twice. The interval counts server ticks, so polling slows down when the server lags.
-
Deleted and emptied files: "empty" means what Bukkit loads without a single key: 0 bytes, only blank lines or comments, or
{}. A file that isn't valid YAML counts as content and firesonChange. Your own reload command still runs on such a file, anddescribeYamlErrorreturnsnullfor it, so a manual reload loads an empty config unless your plugin writes the defaults first. Without anonSkippedhook, the watcher logs an EnglishWARNING. -
Intervals:
intervalTicksgoes down to 1 tick;intervalSecondsandsetIntervalSecondsclamp to at least 1 second, so0seconds means 1 second, not the 5-second default. There is no runtime setter for ticks. -
Pausing:
setEnabled(false)skips the checks.setEnabled(true)on a paused watcher reads the file right away, on the calling thread, and starts from that state: edits made during the pause never fireonChange, edits right after it do. -
Threads:
fileSupplierruns on the async poller thread (and on the calling thread instart()andrefreshBaseline()), so it must not readgetConfig().start()andrefreshBaseline()read and hash the file on the calling thread. -
Your own writes:
saveConfig()or ConfigDefaultsInserter fire the callback unlessrefreshBaseline()follows. -
Folia: it uses the global
BukkitScheduler, so it doesn't work on Folia. -
Lifecycle:
build()doesn't start it, andstart()needs an enabled plugin. Afterstop(), a callback that was already queued still runs once. -
Errors: exceptions in
onChangeare logged and swallowed. A failing poll (e.g. a supplier that throws) logs aWARNINGon every interval while it lasts, and a file that turns unreadable or readable again counts as a change. -
No details:
onChangeis a plainRunnable; it doesn't say what was edited. FileChangeTracker lists what changed. -
describeYamlErrorchecks syntax only: it applies Bukkit's loader rules (valid YAML, a map at the top level), not your values or keys. Duplicate keys pass, and the last one wins. A missing, empty or comments-only file and a folder returnnull; an unreadable file returnsthe file couldn't be read: .... Only the first error is reported, in SnakeYAML's English wording, and the file is read separately from your laterreloadConfig().
- Provide a
File(or supplier) for the file that should be watched. - Register an
onChangecallback that reloads configs and refreshes the baseline. - Wire an enable toggle + polling interval, then call
start()on plugin enable.
-
ConfigWatcherclass innet.kroet.turtlelib.helper. -
ConfigWatcher#builder(JavaPlugin)for fluent construction. -
ConfigWatcher#refreshBaseline()to sync fingerprints after manual writes. A file that is missing or empty at that point isn't reported byonSkippedeither. -
ConfigWatcher.SkipReason(MISSING,EMPTY) is passed toonSkipped. -
ConfigWatcher#start()/stop()to (re)schedule or cancel polling;start()refreshes the baseline first. -
ConfigWatcher#setEnabled(boolean)to pause/resume polling at runtime (call it from the main thread, e.g. after a reload). -
ConfigWatcher#setIntervalSeconds(long)to change the polling interval at runtime, e.g. after a reload (main thread, clamped to at least 1 second). It only reschedules a running watcher if the value changed, keeps the baseline, and returnsfalseif nothing changed. -
ConfigWatcher.describeYamlError(File)(static) returnsnullif the file is valid YAML or missing, otherwise a short description such asline 5, column 28: expected <block end>, but found '<scalar>'.
| Method | Purpose | Default |
|---|---|---|
file(File) / fileSupplier(Supplier<File>)
|
Which file to watch. Supplier lets you recompute paths. | required |
onChange(Runnable) |
Main-thread callback after change detection. | required |
onSkipped(Consumer<SkipReason>) |
Main-thread callback, once per state, when the file goes missing or empty. | English log |
enabled(boolean) |
Initial polling state; change it later with ConfigWatcher#setEnabled(boolean). |
true |
intervalTicks(long) |
Poll every N ticks (20 ticks = 1s). Values <= 0 fall back to the default. |
100 (~5s) |
intervalSeconds(long) |
Convenience variant that clamps to ≥1 second. | 5 |
import net.kroet.turtlelib.helper.ConfigWatcher;
public final class ExamplePlugin extends JavaPlugin {
private ConfigWatcher watcher;
@Override
public void onEnable() {
watcher = ConfigWatcher.builder(this)
.fileSupplier(() -> new File(getDataFolder(), "config.yml"))
.enabled(getConfig().getBoolean("config_watch_enabled", true))
.intervalSeconds(5) // optional, clamps to >=1
.onChange(this::reloadSafely)
.onSkipped(reason -> getLogger().warning("config.yml is " + (reason == ConfigWatcher.SkipReason.MISSING
? "gone" : "empty") + "; keeping the current settings"))
.build();
watcher.start();
}
@Override
public void onDisable() {
if (watcher != null) {
watcher.stop();
}
}
private void reloadSafely() {
String error = ConfigWatcher.describeYamlError(new File(getDataFolder(), "config.yml"));
if (error != null) {
getLogger().warning("config.yml isn't valid YAML (" + error + "); keeping the previous settings");
watcher.refreshBaseline();
return;
}
reloadConfig();
// merge defaults, rebuild caches, etc.
if (watcher != null) {
watcher.setEnabled(getConfig().getBoolean("config_watch_enabled", true));
watcher.refreshBaseline();
}
}
}- Exceptions inside
onChangeare logged and swallowed so the watcher keeps running. - Call
refreshBaseline()after manual reloads to align the stored fingerprint with the freshly-written file. -
JavaPlugin#reloadConfig()doesn't fail on a file that isn't valid YAML: it logs a SEVERE stack trace and leaves an empty config, so everygetConfig()value falls back to the bundled default (a feature the admin switched off turns back on). Check the file withdescribeYamlError(File)first and skip the reload if it returns an error. It reads the file the same way Bukkit does (UTF-8, undecodable bytes replaced), so a file Bukkit would load, e.g. one saved as ANSI, passes. To avoid the SEVERE trace at startup too, overridegetConfig()/reloadConfig()so they check before loading. - Polling task runs async;
onChangealways executes on the Bukkit main thread viaBukkitScheduler. - The async poller only reads a volatile enabled flag and never touches your
FileConfiguration, so read config values on the main thread and push them in viasetEnabled(...)andsetIntervalSeconds(...). Calling both from theonChangecallback is fine; there is no need tostart()the watcher again on a reload.
- Call
MessageService.reload(...)from the change callback to keep locales synced after edits. - Lang files can be watched too, one watcher per file, with
LangLoader.syncLocale(...)orMessageService#loadin the callback.