Skip to content

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.

Why Use It? 🎯

  • 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.

Highlights ✨

  • 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. onSkipped reports 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 manual refreshBaseline() control.
  • describeYamlError(File) checks the file before a reload, so a typo doesn't silently turn every setting back to its default.

Limitations ⚠️

  • 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 fires onChange. Your own reload command still runs on such a file, and describeYamlError returns null for it, so a manual reload loads an empty config unless your plugin writes the defaults first. Without an onSkipped hook, the watcher logs an English WARNING.
  • Intervals: intervalTicks goes down to 1 tick; intervalSeconds and setIntervalSeconds clamp to at least 1 second, so 0 seconds 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 fire onChange, edits right after it do.
  • Threads: fileSupplier runs on the async poller thread (and on the calling thread in start() and refreshBaseline()), so it must not read getConfig(). start() and refreshBaseline() read and hash the file on the calling thread.
  • Your own writes: saveConfig() or ConfigDefaultsInserter fire the callback unless refreshBaseline() follows.
  • Folia: it uses the global BukkitScheduler, so it doesn't work on Folia.
  • Lifecycle: build() doesn't start it, and start() needs an enabled plugin. After stop(), a callback that was already queued still runs once.
  • Errors: exceptions in onChange are logged and swallowed. A failing poll (e.g. a supplier that throws) logs a WARNING on every interval while it lasts, and a file that turns unreadable or readable again counts as a change.
  • No details: onChange is a plain Runnable; it doesn't say what was edited. FileChangeTracker lists what changed.
  • describeYamlError checks 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 return null; an unreadable file returns the file couldn't be read: .... Only the first error is reported, in SnakeYAML's English wording, and the file is read separately from your later reloadConfig().

Quick Start 🚀

  1. Provide a File (or supplier) for the file that should be watched.
  2. Register an onChange callback that reloads configs and refreshes the baseline.
  3. Wire an enable toggle + polling interval, then call start() on plugin enable.

References 📚

  • ConfigWatcher class in net.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 by onSkipped either.
  • ConfigWatcher.SkipReason (MISSING, EMPTY) is passed to onSkipped.
  • 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 returns false if nothing changed.
  • ConfigWatcher.describeYamlError(File) (static) returns null if the file is valid YAML or missing, otherwise a short description such as line 5, column 28: expected <block end>, but found '<scalar>'.

Builder Reference ⚙️

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

Examples 🧪

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();
        }
    }
}

Tips & Notes 💡

  • Exceptions inside onChange are 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 every getConfig() value falls back to the bundled default (a feature the admin switched off turns back on). Check the file with describeYamlError(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, override getConfig()/reloadConfig() so they check before loading.
  • Polling task runs async; onChange always executes on the Bukkit main thread via BukkitScheduler.
  • 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 via setEnabled(...) and setIntervalSeconds(...). Calling both from the onChange callback is fine; there is no need to start() the watcher again on a reload.

Relationships to Other Helpers 🔗

  • 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(...) or MessageService#load in the callback.

Clone this wiki locally