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
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>dev.spexx</groupId>
<artifactId>ConfigurationAPI</artifactId>
<version>1.0.0</version>
<version>1.0.4</version>
<packaging>jar</packaging>

<name>ConfigurationAPI</name>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,31 +19,35 @@
* Watches a single configuration file and triggers atomic reload operations
* when file system changes are detected.
*
* <p>This class monitors the parent directory of the target configuration file
* <p>This watcher monitors the parent directory of a target configuration file
* using {@link WatchService} and filters events specific to the file name.</p>
*
* <p>In addition to change detection, this watcher performs line-level diff
* analysis between file snapshots, producing structured {@link ConfigLineDifference}
* instances for downstream consumers.</p>
* <p>It performs checksum validation and line-level diff analysis to detect changes.
* Reload operations are delegated to an external callback and dispatched on the
* main server thread.</p>
*
* <p>Reload operations are delegated to an external callback, typically managed
* by {@link dev.spexx.configurationAPI.manager.ConfigManager}, ensuring that
* configuration replacement is performed atomically.</p>
* <h2>Features</h2>
* <ul>
* <li>Checksum-based change detection (SHA-256)</li>
* <li>Debounced file system event handling</li>
* <li>Retry-based safe file reading (prevents partial reads)</li>
* <li>Lazy diff computation with O(n) complexity</li>
* <li>Diff size limiting to prevent memory pressure</li>
* <li>Thread-safe configuration reference updates</li>
* </ul>
*
* @apiNote
* This watcher runs on a dedicated daemon thread and schedules reload execution
* on the main server thread. Consumers should avoid performing blocking work
* inside event handlers triggered by this watcher.
* This watcher runs on a dedicated daemon thread. Reload callbacks and event
* dispatching are always executed on the main server thread.
*
* @implSpec
* Change detection is guarded by SHA-256 checksums computed via
* {@link FileChecksum}. Diff computation is performed using a manual
* line parsing algorithm with O(n) complexity.
* File changes are detected via {@link WatchService} and validated using
* SHA-256 checksums. Diff computation uses a manual line-splitting algorithm
* to avoid regex overhead.
*
* @implNote
* A custom line-splitting implementation is used instead of
* {@link String#split(String)} to avoid regex overhead. Diff lists are lazily
* allocated and skipped entirely when no changes are detected.
* Some editors perform atomic file replacement (delete + recreate). This class
* includes safeguards against transient file states and partial reads.
*
* @since 1.0.0
*/
Expand All @@ -60,16 +64,20 @@ public final class YamlConfigWatcher {
private Thread thread;

private final AtomicBoolean running = new AtomicBoolean(false);

private volatile long lastReload = 0;
private volatile long lastEventTime = 0;

private static final long DEBOUNCE_MS = 300;
private static final long MIN_EVENT_INTERVAL_MS = 100;
private static final int MAX_DIFFS = 10_000;

/**
* Constructs a new {@code YamlConfigWatcher}.
*
* @param plugin the owning plugin instance
* @param yamlConfig the configuration to monitor
* @param reloadCallback the reload callback
* @param plugin the owning plugin instance, must not be {@code null}
* @param yamlConfig the configuration to monitor, must not be {@code null}
* @param reloadCallback callback responsible for performing reload logic
*
* @since 1.0.0
*/
Expand All @@ -86,7 +94,7 @@ public YamlConfigWatcher(@NotNull JavaPlugin plugin,
/**
* Starts monitoring the configuration file.
*
* @throws IOException if watcher cannot be initialized
* @throws IOException if the watcher cannot be initialized
*
* @since 1.0.0
*/
Expand All @@ -112,13 +120,13 @@ public void start() throws IOException {

running.set(true);

thread = new Thread(this::run, "Watcher-" + filePath.getFileName());
thread = new Thread(this::run, "ConfigWatcher-" + filePath.getFileName());
thread.setDaemon(true);
thread.start();
}

/**
* Stops the watcher.
* Stops monitoring and releases resources.
*
* @since 1.0.0
*/
Expand All @@ -135,22 +143,7 @@ public void stop() {
/**
* Updates the tracked configuration reference.
*
* <p>This method is typically invoked after an atomic configuration swap
* to ensure the watcher operates on the latest {@link YamlConfig} instance.</p>
*
* @param config the new configuration instance, must not be {@code null}
*
* @apiNote
* This method does not trigger a reload. It only updates the internal reference
* used for subsequent change detection.
*
* @implSpec
* The reference is updated using a {@code volatile} write to guarantee
* visibility across threads.
*
* @implNote
* This method is expected to be called by the managing component immediately
* after replacing the configuration instance.
* @param config new configuration instance
*
* @since 1.0.0
*/
Expand All @@ -167,8 +160,13 @@ public void updateChecksum() {
this.lastChecksum = FileChecksum.sha256(yamlConfig.file());
}

/**
* Main watcher loop.
*/
private void run() {

final Path targetFileName = yamlConfig.file().toPath().getFileName();

while (running.get()) {
try {
WatchKey key = watchService.take();
Expand All @@ -179,19 +177,34 @@ private void run() {

Path changed = (Path) event.context();

if (!changed.equals(yamlConfig.file().toPath().getFileName())) continue;
if (!changed.equals(targetFileName)) continue;

long now = System.currentTimeMillis();
if (now - lastReload < DEBOUNCE_MS) continue;

if (now - lastEventTime < MIN_EVENT_INTERVAL_MS) continue;
lastEventTime = now;

if (now - lastReload < DEBOUNCE_MS) continue;
lastReload = now;

reload();
}

if (!key.reset()) break;
if (!key.reset()) {
plugin.getLogger().warning("[ConfigWatcher] Watch key invalidated: "
+ targetFileName);
break;
}

} catch (Exception ignored) {
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;

} catch (ClosedWatchServiceException e) {
break;

} catch (Exception e) {
plugin.getLogger().severe("[ConfigWatcher] Watch loop crashed: " + e.getMessage());
break;
}
}
Expand Down Expand Up @@ -242,8 +255,11 @@ private void reload() {

if (!oldLine.equals(newLine)) {

if (diffs == null) {
diffs = new ArrayList<>(max);
if (diffs == null) diffs = new ArrayList<>(max);

if (diffs.size() >= MAX_DIFFS) {
plugin.getLogger().warning("[ConfigWatcher] Diff limit reached, truncating.");
break;
}

diffs.add(new ConfigLineDifference(i + 1, oldLine, newLine));
Expand All @@ -257,6 +273,8 @@ private void reload() {
lastChecksum = newChecksum;
lastContent = newContent;

if (!plugin.isEnabled()) return;

final ConfigChangeSummary summary = new ConfigChangeSummary(changed, added, removed);
final List<ConfigLineDifference> finalDiffs =
diffs == null ? List.of() : List.copyOf(diffs);
Expand All @@ -278,7 +296,7 @@ private void reload() {
}

/**
* Splits file content into lines without using regex.
* Splits content into lines without regex.
*
* @since 1.0.3
*/
Expand All @@ -299,18 +317,32 @@ private void reload() {
}

/**
* Reads file content safely.
* Reads file content safely with retry mechanism.
*
* @since 1.0.1
* @since 1.0.4
*/
private @NotNull String readContentSafe(File file) {
try {
return Files.readString(file.toPath());
} catch (Exception e) {
plugin.getLogger().warning(
"[ConfigWatcher] Failed to read config file: " + file.getAbsolutePath()
);
return "";
private @NotNull String readContentSafe(@NotNull File file) {

Path path = file.toPath();

for (int i = 0; i < 3; i++) {
try {
return Files.readString(path);
} catch (IOException e) {
try {
Thread.sleep(10);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
break;
}
}
}

plugin.getLogger().warning(
"[ConfigWatcher] Failed to read config file after retries: "
+ file.getAbsolutePath()
);

return "";
}
}
2 changes: 1 addition & 1 deletion src/main/resources/paper-plugin.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: ConfigurationAPI
description: $description
version: '1.0.0'
version: '$version'

main: dev.spexx.configurationAPI.ConfigurationAPI
api-version: '1.21.11'
Expand Down