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,18 +1,15 @@
package com.github.minecraft_ta.totalDebugCompanion.pack;

import com.github.minecraft_ta.totalDebugCompanion.resource.ResourceLoader;
import com.github.minecraft_ta.totalDebugCompanion.util.FileWatch;
import com.github.minecraft_ta.totalDebugCompanion.util.Strand;
import com.github.minecraft_ta.totalDebugCompanion.util.Workers;

import java.awt.Desktop;
import java.io.IOException;
import java.lang.ProcessBuilder.Redirect;
import java.nio.file.ClosedWatchServiceException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
Expand All @@ -22,10 +19,7 @@
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;

/**
Expand All @@ -43,10 +37,8 @@ public final class ExternalEdits implements AutoCloseable {
private final ResourceEdits edits;
/** The files followed, by absolute file. */
private final Map<Path, Followed> followed = new ConcurrentHashMap<>();
/** The folders watched, by folder. */
private final Map<Path, WatchKey> folders = new ConcurrentHashMap<>();
private WatchService watcher;
private ScheduledExecutorService settle;
/** Where a save is taken, one at a time, as it is adopted in order. */
private final Strand strand = Workers.strand();
private boolean closed;

/** A followed file, what it held when a program first opened it, and who hears about its saves. */
Expand All @@ -55,7 +47,10 @@ private static final class Followed {
final Path pack;
final byte[] before;
final List<BiConsumer<ResourceEdits.Saved, Throwable>> listeners = new CopyOnWriteArrayList<>();
Runnable unwatch = () -> { };
ScheduledFuture<?> pending;
/** Counts the writes seen, so a take handed on before a later write takes nothing. Under the edits' lock. */
long writes;

Followed(String path, Path pack, byte[] before) {
this.path = path;
Expand All @@ -79,7 +74,7 @@ public void open(String path, Path pack, String program) throws IOException {
launch(program, file);
} catch (IOException | RuntimeException failed) {
// Nothing was opened, so nothing else's saves are taken: only a file followed before stays followed.
if (!followedBefore) this.followed.remove(file);
if (!followedBefore) stopFollowing(file);
throw failed;
}
}
Expand All @@ -93,14 +88,12 @@ Path follow(String path, Path pack) throws IOException {
if (!Files.isRegularFile(file)) throw new IOException(file + " does not exist");
synchronized (this) {
if (this.closed) throw new IOException("The project is closing");
if (this.watcher == null) start();
// A folder deleted and made again since lost its watch, which is registered anew.
Path folder = file.getParent();
if (!this.folders.containsKey(folder)) {
this.folders.put(folder, folder.register(this.watcher, StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY));
if (!this.followed.containsKey(file)) {
Followed followed = new Followed(path, pack, Files.readAllBytes(file));
Path name = file.getFileName();
followed.unwatch = FileWatch.shared().watch(file.getParent(), name::equals, () -> settle(followed));
this.followed.put(file, followed);
}
if (!this.followed.containsKey(file)) this.followed.put(file, new Followed(path, pack, Files.readAllBytes(file)));
}
return file;
}
Expand All @@ -121,51 +114,26 @@ public boolean follows(String path, Path pack) {
return this.followed.containsKey(pack.resolve(path).toAbsolutePath().normalize());
}

private void start() throws IOException {
this.watcher = FileSystems.getDefault().newWatchService();
this.settle = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "TotalDebug external edits");
thread.setDaemon(true);
return thread;
});
WatchService watching = this.watcher;
Thread thread = new Thread(() -> watch(watching), "TotalDebug external edit watcher");
thread.setDaemon(true);
thread.start();
}

private void watch(WatchService watching) {
while (true) {
WatchKey key;
try {
key = watching.take();
} catch (InterruptedException | ClosedWatchServiceException stopped) {
return;
}
Path folder = (Path) key.watchable();
for (WatchEvent<?> event : key.pollEvents()) {
if (event.kind() == StandardWatchEventKinds.OVERFLOW) {
// Which files changed is lost: every followed file of the folder is looked at.
this.followed.forEach((file, followedFile) -> {
if (file.getParent().equals(folder)) settle(followedFile);
});
} else if (event.context() instanceof Path name) {
Followed file = this.followed.get(folder.resolve(name));
if (file != null) settle(file);
}
}
if (!key.reset()) this.folders.remove(folder);
}
private synchronized void stopFollowing(Path file) {
Followed followed = this.followed.remove(file);
if (followed == null) return;
followed.unwatch.run();
if (followed.pending != null) followed.pending.cancel(false);
}

/** Takes the file once it stayed unchanged for a moment. */
private synchronized void settle(Followed file) {
if (this.closed) return;
if (file.pending != null) file.pending.cancel(false);
file.pending = this.settle.schedule(() -> take(file), SETTLE_MILLIS, TimeUnit.MILLISECONDS);
long write = ++file.writes;
file.pending = Workers.later(SETTLE_MILLIS, this.strand, () -> take(file, write));
}

private void take(Followed file) {
private void take(Followed file, long write) {
synchronized (this) {
// Another write came since, whose own settle takes the file.
if (this.closed || write != file.writes) return;
Comment on lines +133 to +135

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Revalidate the take after reading the file

If a later write event or project close occurs after this check but while Files.readAllBytes is in progress, as can happen with a large or slow resource, the task still calls adopt with content its generation no longer owns; non-PNG partial bytes can consequently be recorded and reloaded, and an in-flight take can survive close(). The fresh race remains after the queued-task fix because validation occurs only before the blocking read; recheck closed and write == file.writes after reading, as FileReading.readHere does before publishing.

AGENTS.md reference: AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

}
Path location = file.pack.resolve(file.path);
byte[] seen;
try {
Expand All @@ -177,7 +145,12 @@ private void take(Followed file) {
// Still being written; the program's next write comes as another event.
return;
}
this.edits.adopt(file.path, file.pack, file.before, seen).whenComplete((saved, failure) -> {
CompletableFuture<ResourceEdits.Saved> adopted;
synchronized (this) {
if (this.closed || write != file.writes) return;
adopted = this.edits.adopt(file.path, file.pack, file.before, seen);
}
adopted.whenComplete((saved, failure) -> {
if (saved == null && failure == null) return;
Throwable cause = failure instanceof CompletionException && failure.getCause() != null ? failure.getCause() : failure;
file.listeners.forEach(listener -> listener.accept(saved, cause));
Expand Down Expand Up @@ -234,21 +207,12 @@ public CompletableFuture<Void> openLater(String path, Path pack, String program)
} catch (IOException exception) {
throw new CompletionException(exception);
}
});
}, Workers.files());
}

@Override
public synchronized void close() {
this.closed = true;
this.followed.clear();
this.folders.clear();
if (this.settle != null) this.settle.shutdownNow();
if (this.watcher != null) {
try {
this.watcher.close();
} catch (IOException ignored) {
// Closing only stops the watch.
}
}
List.copyOf(this.followed.keySet()).forEach(this::stopFollowing);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView;

import com.github.minecraft_ta.totalDebugCompanion.util.FileWatch;
import com.github.minecraft_ta.totalDebugCompanion.Icons;
import com.github.minecraft_ta.totalDebugCompanion.decompile.CompanionDecompilationService;
import com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView.lazyFileTree.*;
import com.github.minecraft_ta.totalDebugCompanion.ui.presentation.PrimarySecondaryText;
import com.github.minecraft_ta.totalDebugCompanion.util.FileUtils;

import java.io.IOException;
import java.io.UncheckedIOException;
Expand All @@ -19,7 +19,7 @@ final class DecompiledSourcesTreeItem extends DirectoryTreeItem {
super("decompiled-files");
this.service = service;
setIcon(Icons.FOLDER);
this.stopWatching = FileUtils.startNewDirectoryWatcher(service.cacheDirectory(),
this.stopWatching = FileWatch.shared().watchEntries(service.cacheDirectory(),
() -> tree.loadItemsForTopLevelItem(this));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView;

import com.github.minecraft_ta.totalDebugCompanion.util.FileWatch;
import com.github.minecraft_ta.totalDebugCompanion.notification.NotificationCenter.Source;
import com.github.minecraft_ta.totalDebugCompanion.notification.NotificationCenter.Severity;
import com.formdev.flatlaf.util.SystemFileChooser;
Expand All @@ -19,7 +20,6 @@
import com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView.lazyFileTree.FileSystemFileItem;
import com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView.lazyFileTree.LazyTreeNode;
import com.github.minecraft_ta.totalDebugCompanion.ui.views.FileNamePopup;
import com.github.minecraft_ta.totalDebugCompanion.util.FileUtils;

import javax.swing.*;
import javax.swing.tree.TreePath;
Expand Down Expand Up @@ -373,7 +373,7 @@ private CompletableFuture<Void> execute(List<Path> roots, boolean deleting, Work
ctx.project().requireActive();
for (Path root : roots) ctx.project().scriptFiles().mutable(root);
for (var entry : drafts.entrySet()) { entry.getKey().pendingSave().join(); entry.getKey().persist(entry.getValue()); saved.add(entry.getKey()); }
FileUtils.withPausedDirectoryWatchers(roots, () -> work.run(ctx.project().scriptFiles(), changes));
FileWatch.shared().pausing(roots, () -> work.run(ctx.project().scriptFiles(), changes));
} catch (IOException failure) { throw new CompletionException(failure); }
finally {
if (deleting) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView.lazyFileTree;

import com.github.minecraft_ta.totalDebugCompanion.util.FileWatch;
import com.github.minecraft_ta.totalDebugCompanion.ui.Tooltip;
import com.github.minecraft_ta.totalDebugCompanion.Icons;
import com.github.minecraft_ta.totalDebugCompanion.util.FileUtils;

import javax.swing.*;
import java.io.IOException;
Expand Down Expand Up @@ -34,7 +34,7 @@ public class FileSystemDirectoryItem extends DirectoryTreeItem {
setIcon(Icons.FOLDER);

this.stopWatching = watch
? FileUtils.startNewDirectoryWatcher(path, () -> {
? FileWatch.shared().watchEntries(path, () -> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve recovery for linked tree directories

When a displayed directory is a symlink or Windows junction and its target is deleted and later recreated, this migration leaves the tree stale. After the target's watch key becomes invalid, FileWatch.place tests the logical link path, finds it is no longer a directory, and watches the link's parent; recreating the target changes only the target's parent, so no event reattaches the watch or clears the tree's error state. The former FileUtils registration resolved and retried the real target path, while createChildIfPresent deliberately admits linked directories, so FileWatch needs to preserve recovery through the real target before this consumer moves to it.

AGENTS.md reference: AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

changed = true;
SwingUtilities.invokeLater(() -> lazyFileJTree.refreshDirectory(path));
})
Expand Down
Loading