From b24d03c06d3b86af129bdf91afa27e2e0b4495f8 Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:57:53 +0200 Subject: [PATCH 1/4] The Project tree's folders and external edits register with the one FileWatch FileWatch gains watchEntries, told only of entries created or removed, and pausing, which lets go of every watch on or inside some folders while Companion moves them. FileUtils, which was only its own watcher, goes; ExternalEdits registers each file with FileWatch and settles on the timer and a strand. SYSTEMS.md records the pause and that the logs, configuration and resource packs pages keep reading whenever they are shown. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../pack/ExternalEdits.java | 91 ++------ .../treeView/DecompiledSourcesTreeItem.java | 4 +- .../treeView/ScriptFileActions.java | 4 +- .../lazyFileTree/FileSystemDirectoryItem.java | 4 +- .../totalDebugCompanion/util/FileUtils.java | 205 ------------------ .../totalDebugCompanion/util/FileWatch.java | 107 +++++++-- .../totalDebugCompanion/SystemsRulesTest.java | 15 +- .../pack/ExternalEditsTest.java | 17 ++ .../lazyFileTree/DirectoryChainTest.java | 16 +- .../util/DirectoryWatcherTest.java | 201 ----------------- .../util/FileWatchTest.java | 167 ++++++++++++++ docs/SYSTEMS.md | 14 +- 12 files changed, 323 insertions(+), 522 deletions(-) delete mode 100644 companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileUtils.java delete mode 100644 companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/DirectoryWatcherTest.java create mode 100644 companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEdits.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEdits.java index 9708bc189..cd8a20b1c 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEdits.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEdits.java @@ -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; @@ -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; /** @@ -43,10 +37,8 @@ public final class ExternalEdits implements AutoCloseable { private final ResourceEdits edits; /** The files followed, by absolute file. */ private final Map followed = new ConcurrentHashMap<>(); - /** The folders watched, by folder. */ - private final Map 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. */ @@ -55,6 +47,7 @@ private static final class Followed { final Path pack; final byte[] before; final List> listeners = new CopyOnWriteArrayList<>(); + Runnable unwatch = () -> { }; ScheduledFuture pending; Followed(String path, Path pack, byte[] before) { @@ -79,7 +72,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; } } @@ -93,14 +86,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; } @@ -121,51 +112,24 @@ 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); + file.pending = Workers.later(SETTLE_MILLIS, this.strand, () -> take(file)); } private void take(Followed file) { + synchronized (this) { + if (this.closed) return; + } Path location = file.pack.resolve(file.path); byte[] seen; try { @@ -234,21 +198,12 @@ public CompletableFuture 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); } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/DecompiledSourcesTreeItem.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/DecompiledSourcesTreeItem.java index 2d772ffc6..e7ed5bf5a 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/DecompiledSourcesTreeItem.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/DecompiledSourcesTreeItem.java @@ -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; @@ -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)); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActions.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActions.java index 01328c314..4d434f200 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActions.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActions.java @@ -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; @@ -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; @@ -373,7 +373,7 @@ private CompletableFuture execute(List 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) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/FileSystemDirectoryItem.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/FileSystemDirectoryItem.java index 2289f2fae..64de6a214 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/FileSystemDirectoryItem.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/FileSystemDirectoryItem.java @@ -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; @@ -34,7 +34,7 @@ public class FileSystemDirectoryItem extends DirectoryTreeItem { setIcon(Icons.FOLDER); this.stopWatching = watch - ? FileUtils.startNewDirectoryWatcher(path, () -> { + ? FileWatch.shared().watchEntries(path, () -> { changed = true; SwingUtilities.invokeLater(() -> lazyFileJTree.refreshDirectory(path)); }) diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileUtils.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileUtils.java deleted file mode 100644 index 05ef3a47b..000000000 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileUtils.java +++ /dev/null @@ -1,205 +0,0 @@ -package com.github.minecraft_ta.totalDebugCompanion.util; - -import java.io.IOException; -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.WatchKey; -import java.nio.file.WatchService; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.ArrayList; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; - -/** Shares one watcher thread across materialized directories; subscriptions own their registrations. */ -public final class FileUtils { - private static WatchService service; - private static final Map registrations = new HashMap<>(); - private static final Map pausedRoots = new HashMap<>(); - // Native registration/cancellation can block. Never hold the state monitor while doing it. - private static final Object registrationLock = new Object(); - - @FunctionalInterface interface DirectoryRegistration { - WatchKey register(Path path, WatchService service) throws IOException; - } - - private static final class Registration { - final Set listeners = new HashSet<>(); - final DirectoryRegistration registrar; - WatchKey key; - Registration(DirectoryRegistration registrar) { this.registrar = registrar; } - } - - public static Runnable startNewDirectoryWatcher(Path directory, Runnable onChange) { - return startNewDirectoryWatcher(directory, onChange, - (path, watching) -> path.register(watching, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE)); - } - - static Runnable startNewDirectoryWatcher(Path directory, Runnable onChange, DirectoryRegistration register) { - Path path; - try { path = directory.toRealPath(); } - catch (IOException failure) { throw new IllegalStateException("Unable to watch directory " + directory, failure); } - Registration registration; - boolean added; - synchronized (FileUtils.class) { - registration = registrations.computeIfAbsent(path, ignored -> new Registration(register)); - added = registration.listeners.add(onChange); - } - try { register(path, registration); } - catch (IOException | RuntimeException failure) { - if (added) unsubscribe(path, onChange); - throw new IllegalStateException("Unable to watch directory " + path, failure); - } - return () -> unsubscribe(path, onChange); - } - - private static boolean register(Path path, Registration registration) throws IOException { - synchronized (registrationLock) { - WatchService current; - synchronized (FileUtils.class) { - if (registrations.get(path) != registration || registration.listeners.isEmpty() - || registration.key != null && registration.key.isValid() || paused(path)) return false; - current = service; - } - if (current == null) { - current = FileSystems.getDefault().newWatchService(); - synchronized (FileUtils.class) { service = current; } - WatchService watching = current; - Thread.ofPlatform().daemon().name("directory-watcher").start(() -> watch(watching)); - } - WatchKey key = registration.registrar.register(path, current); - synchronized (FileUtils.class) { - if (registrations.get(path) == registration && !registration.listeners.isEmpty() && !paused(path)) { - registration.key = key; - return true; - } - } - key.cancel(); - return false; - } - } - - private static boolean paused(Path path) { return pausedRoots.keySet().stream().anyMatch(path::startsWith); } - - @FunctionalInterface public interface FileOperation { void run() throws IOException; } - - /** Windows cannot rename a directory while a descendant has an open watch handle. */ - public static void withPausedDirectoryWatchers(List roots, FileOperation operation) throws IOException { - Set paths = new HashSet<>(); - for (Path root : roots) paths.add(root.toRealPath()); - synchronized (FileUtils.class) { - paths.forEach(path -> pausedRoots.merge(path, 1, Integer::sum)); - } - try { - // Drain in-flight registration before moving; it sees the pause and cancels its result. - synchronized (registrationLock) { - var keys = new ArrayList(); - synchronized (FileUtils.class) { - registrations.forEach((path, registration) -> { - if (paths.stream().anyMatch(path::startsWith) && registration.key != null) { - keys.add(registration.key); - registration.key = null; - } - }); - } - keys.forEach(WatchKey::cancel); - } - operation.run(); - } - finally { - Set callbacks = new HashSet<>(); - Map affected = new HashMap<>(); - synchronized (FileUtils.class) { - paths.forEach(path -> pausedRoots.computeIfPresent(path, (ignored, count) -> count == 1 ? null : count - 1)); - registrations.forEach((path, registration) -> { - if (!paths.stream().anyMatch(path::startsWith)) return; - callbacks.addAll(registration.listeners); - affected.put(path, registration); - }); - } - affected.forEach((path, registration) -> { - if (Files.isDirectory(path)) { - try { register(path, registration); } - catch (IOException failure) { logFailure(failure); } - } - }); - // The operation may have changed contents while events were paused, including on failure. - callbacks.forEach(FileUtils::notifyListener); - } - } - - private static void watch(WatchService current) { - try { - while (true) { - WatchKey key = current.poll(1, TimeUnit.SECONDS); - Set callbacks = new HashSet<>(); - if (key != null) { - boolean changed = !key.pollEvents().isEmpty(); - boolean valid = key.reset(); - synchronized (FileUtils.class) { - registrations.values().stream().filter(registration -> registration.key == key).forEach(registration -> { - if (changed || !valid) callbacks.addAll(registration.listeners); - if (!valid) registration.key = null; - }); - } - } - Map pending; - synchronized (FileUtils.class) { - if (service != current) return; - pending = new HashMap<>(registrations); - } - pending.forEach((path, registration) -> { - try { - if (register(path, registration)) synchronized (FileUtils.class) { callbacks.addAll(registration.listeners); } - } catch (IOException unavailable) { - // Keep the subscription while an externally removed directory is unavailable. - } - }); - callbacks.forEach(FileUtils::notifyListener); - } - } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } - catch (ClosedWatchServiceException ignored) { } - } - - private static void notifyListener(Runnable callback) { - try { callback.run(); } catch (RuntimeException failure) { logFailure(failure); } - } - - private static void logFailure(Exception failure) { - System.getLogger(FileUtils.class.getName()).log(System.Logger.Level.WARNING, "Directory refresh failed", failure); - } - - private static void unsubscribe(Path path, Runnable callback) { - synchronized (FileUtils.class) { - var registration = registrations.get(path); - if (registration == null) return; - registration.listeners.remove(callback); - if (!registration.listeners.isEmpty()) return; - // Keep empty registrations visible to a concurrent rename pause until cleanup drains them. - } - CompletableFuture.runAsync(FileUtils::closeUnusedRegistrations); - } - - private static void closeUnusedRegistrations() { - synchronized (registrationLock) { - var keys = new ArrayList(); - WatchService unused = null; - synchronized (FileUtils.class) { - registrations.values().removeIf(registration -> { - if (!registration.listeners.isEmpty()) return false; - if (registration.key != null) keys.add(registration.key); - return true; - }); - if (registrations.isEmpty()) { unused = service; service = null; } - } - keys.forEach(WatchKey::cancel); - if (unused != null) try { unused.close(); } catch (IOException failure) { logFailure(failure); } - } - } -} diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java index 2ed0c21f9..253de5dd5 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java @@ -11,6 +11,7 @@ import java.nio.file.WatchService; import java.util.ArrayList; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; @@ -23,16 +24,27 @@ * removed while watched is watched that way again. Where a folder cannot be watched at all, it is tried again after 1 * and 5 seconds and every 30 seconds after, and its follower is told each time, so it reads what it follows then. * Followers are told on the watcher's thread and hand the work on at once. + * + *

On Windows a folder cannot be renamed or moved while it or a folder inside it is watched: Companion's own moves of + * such folders run {@link #pausing}, without watches there.

*/ public final class FileWatch { private static final List RETRY_MILLIS = List.of(1_000L, 5_000L, 30_000L); private static final System.Logger LOGGER = System.getLogger(FileWatch.class.getName()); private static final FileWatch SHARED = new FileWatch(); + /** Runs with no watch on or inside some folders. */ + @FunctionalInterface + public interface Operation { + void run() throws IOException; + } + /** A folder followed, with the names of its entries that matter and what to tell. */ private static final class Watched { final Path folder; final Predicate names; + /** Told only of entries created or removed, not of those written, as a listing of the folder. */ + final boolean entriesOnly; final Runnable changed; /** The folder watched for it: its own, or its nearest existing ancestor; null while it cannot be watched. */ Path at; @@ -41,9 +53,10 @@ private static final class Watched { ScheduledFuture retry; boolean closed; - Watched(Path folder, Predicate names, Runnable changed) { + Watched(Path folder, Predicate names, boolean entriesOnly, Runnable changed) { this.folder = folder; this.names = names; + this.entriesOnly = entriesOnly; this.changed = changed; } } @@ -53,6 +66,8 @@ private static final class Watched { /** The watches by the real path of their folder, so one reached through a link is watched once. */ private final Map keys = new HashMap<>(); private final List watched = new ArrayList<>(); + /** The real paths of the folders no watch may be on or inside, with how many operations pause each. */ + private final Map paused = new HashMap<>(); private FileWatch() { WatchService created = null; @@ -75,8 +90,58 @@ public static FileWatch shared() { * removed, and whenever events may have been lost; returns what stops it. */ public Runnable watch(Path folder, Predicate names, Runnable changed) { - Watched followed = new Watched(folder.toAbsolutePath().normalize(), Objects.requireNonNull(names, "names"), - Objects.requireNonNull(changed, "changed")); + return watch(new Watched(folder.toAbsolutePath().normalize(), Objects.requireNonNull(names, "names"), false, + Objects.requireNonNull(changed, "changed"))); + } + + /** + * Tells {@code changed} after an entry of {@code folder} was created or removed, not when one was written, and + * whenever events may have been lost; returns what stops it. For a listing of the folder. + */ + public Runnable watchEntries(Path folder, Runnable changed) { + return watch(new Watched(folder.toAbsolutePath().normalize(), name -> true, true, Objects.requireNonNull(changed, "changed"))); + } + + /** + * Runs {@code operation} with no watch on or inside {@code roots}, so it can rename or move them on Windows, then + * watches them again and tells their followers, since the operation changed what they follow. Blocking. + */ + public void pausing(List roots, Operation operation) throws IOException { + List real = new ArrayList<>(); + for (Path root : roots) real.add(root.toRealPath()); + synchronized (this) { + real.forEach(root -> this.paused.merge(root, 1, Integer::sum)); + for (Iterator> keys = this.keys.entrySet().iterator(); keys.hasNext(); ) { + Map.Entry entry = keys.next(); + if (!paused(entry.getKey())) continue; + keys.remove(); + entry.getValue().cancel(); + for (Watched followed : this.watched) { + if (followed.key != entry.getValue()) continue; + followed.key = null; + followed.at = null; + } + } + } + try { + operation.run(); + } finally { + List tell = new ArrayList<>(); + synchronized (this) { + real.forEach(root -> this.paused.computeIfPresent(root, (ignored, count) -> count == 1 ? null : count - 1)); + for (Watched followed : this.watched) { + if (followed.key != null) continue; + if (followed.retry != null) followed.retry.cancel(false); + place(followed, 0); + tell.add(followed.changed); + } + } + // Also where the operation failed: it may have changed something before. + tell.forEach(FileWatch::tell); + } + } + + private Runnable watch(Watched followed) { synchronized (this) { this.watched.add(followed); place(followed, 0); @@ -124,6 +189,8 @@ private WatchKey register(Path folder) { if (this.service == null) return null; try { Path real = folder.toRealPath(); + // Watched again once the operation that pauses it ended. + if (paused(real)) return null; WatchKey key = this.keys.get(real); if (key != null && key.isValid()) return key; key = real.register(this.service, StandardWatchEventKinds.ENTRY_CREATE, @@ -135,6 +202,14 @@ private WatchKey register(Path folder) { } } + /** Whether {@code real} is a paused folder or inside one. Under the lock. */ + private boolean paused(Path real) { + for (Path root : this.paused.keySet()) { + if (real.startsWith(root)) return true; + } + return false; + } + /** Stops the watch {@code key} once nothing followed uses it. Under the lock. */ private void release(WatchKey key) { if (key == null) return; @@ -157,6 +232,15 @@ private void retryLater(Watched followed, int attempt) { }); } + /** Tells a follower; one follower's failure does not stop the watch of every other. */ + private static void tell(Runnable changed) { + try { + changed.run(); + } catch (RuntimeException failure) { + LOGGER.log(System.Logger.Level.WARNING, "A follower of a watched folder failed", failure); + } + } + /** Whether an entry named among {@code names} matters to {@code followed}; a follower's failing names tell nothing. */ private static boolean concerns(Watched followed, List names) { try { @@ -173,10 +257,14 @@ private void run() { while (true) { WatchKey key = this.service.take(); List names = new ArrayList<>(); + List entries = new ArrayList<>(); boolean lost = false; for (WatchEvent event : key.pollEvents()) { if (event.kind() == StandardWatchEventKinds.OVERFLOW) lost = true; - else if (event.context() instanceof Path name) names.add(name); + else if (event.context() instanceof Path name) { + names.add(name); + if (event.kind() != StandardWatchEventKinds.ENTRY_MODIFY) entries.add(name); + } } List tell = new ArrayList<>(); synchronized (this) { @@ -192,19 +280,12 @@ private void run() { place(followed, 0); tell.add(followed.changed); } - } else if (lost || concerns(followed, names)) { + } else if (lost || concerns(followed, followed.entriesOnly ? entries : names)) { tell.add(followed.changed); } } } - for (Runnable changed : tell) { - try { - changed.run(); - } catch (RuntimeException failure) { - // One follower's failure does not stop the watch of every other. - LOGGER.log(System.Logger.Level.WARNING, "A follower of a watched folder failed", failure); - } - } + tell.forEach(FileWatch::tell); } } catch (InterruptedException | ClosedWatchServiceException stopped) { // The application ends. diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java index eb27e842e..311193b58 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java @@ -88,8 +88,6 @@ private record Allowed(int times, String why) { Map.entry("ui/components/catalog/TextureThumbnails.java", new Allowed(2, "moves onto Workers in PR 8")), Map.entry("ui/components/catalog/ModLogoIcons.java", new Allowed(2, "moves onto Workers in PR 8")), Map.entry("ui/components/editors/ResourceViewPanel.java", new Allowed(2, "moves onto Workers in PR 8")), - Map.entry("pack/ExternalEdits.java", new Allowed(3, "becomes an adoption in PR 6")), - Map.entry("util/FileUtils.java", new Allowed(1, "its watcher becomes a FileWatch follower in PR 6")), Map.entry("util/Workers.java", new Allowed(6, "the file work, the owners' strands and the timer everything shares")), Map.entry("util/FileWatch.java", new Allowed(1, "the one watcher of the folders Companion follows"))); @@ -101,7 +99,6 @@ private record Allowed(int times, String why) { Map.entry("inspection/InspectionSession.java", new Allowed(2, "moves onto Workers in PR 8")), Map.entry("model/CodeView.java", new Allowed(1, "moves onto Workers in PR 8")), Map.entry("navigation/NavigationService.java", new Allowed(2, "moves onto Workers in PR 8")), - Map.entry("pack/ExternalEdits.java", new Allowed(1, "moves onto Workers in PR 8")), Map.entry("script/SnippetExpressionSupport.java", new Allowed(2, "moves onto Workers in PR 8")), Map.entry("ui/components/PageLoader.java", new Allowed(1, "reads on Workers' file work from PR 7")), Map.entry("ui/components/catalog/ModPanel.java", new Allowed(1, "moves onto Workers in PR 8")), @@ -115,10 +112,9 @@ private record Allowed(int times, String why) { Map.entry("ui/components/treeView/lazyFileTree/LazyFileJTree.java", new Allowed(1, "moves onto Workers in PR 8")), Map.entry("ui/views/PrismInstancePicker.java", new Allowed(1, "moves onto Workers in PR 8")), Map.entry("ui/views/debugger/BreakpointsWindow.java", new Allowed(2, "moves onto Workers in PR 8")), - Map.entry("ui/views/debugger/DebuggerInspector.java", new Allowed(1, "moves onto Workers in PR 8")), - Map.entry("util/FileUtils.java", new Allowed(1, "moves onto Workers in PR 8"))); + Map.entry("ui/views/debugger/DebuggerInspector.java", new Allowed(1, "moves onto Workers in PR 8"))); - // Signal itself; events inside a subsystem or a control, which are not state (section 1); owners not on signals yet. + // Signal itself; events inside a subsystem or a control, which are not state (section 1). private static final Map LISTENER_LISTS = Map.ofEntries( Map.entry("util/Signal.java", new Allowed(1, "the signal every owner uses")), Map.entry("jdt/diagnostics/ASTCache.java", new Allowed(1, "the editor's analysis")), @@ -134,13 +130,10 @@ private record Allowed(int times, String why) { Map.entry("ui/components/inspection/DataView.java", new Allowed(1, "speed search, an event")), Map.entry("ui/components/treeView/lazyFileTree/LazyFileJTree.java", new Allowed(1, "a double click, an event")), Map.entry("ui/theme/ThemeManager.java", new Allowed(1, "the theme, which stays as it is")), - Map.entry("util/FileUtils.java", new Allowed(1, "becomes FileWatch in PR 6")), - Map.entry("pack/ExternalEdits.java", new Allowed(1, "becomes an adoption in PR 6"))); + Map.entry("pack/ExternalEdits.java", new Allowed(1, "an external save's result, an event"))); private static final Map WATCHERS = Map.ofEntries( - Map.entry("util/FileWatch.java", new Allowed(1, "the one watcher of the folders Companion follows")), - Map.entry("util/FileUtils.java", new Allowed(1, "becomes a FileWatch follower in PR 6")), - Map.entry("pack/ExternalEdits.java", new Allowed(1, "becomes an adoption in PR 6"))); + Map.entry("util/FileWatch.java", new Allowed(1, "the one watcher of the folders Companion follows"))); @BeforeAll static void scan() throws IOException { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEditsTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEditsTest.java index 94730d81c..ad4e1da5e 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEditsTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEditsTest.java @@ -120,6 +120,23 @@ void aFollowedTextureIsTakenOnceItIsWhole() throws Exception { } } + @Test + void aClosedProjectTakesNoSaveAndHoldsNoFolderOfThePack() throws Exception { + ChangeRecord record = ChangeRecord.inMemory(); + ResourceEdits edits = edits(record); + Path pack = edits.save(TEXTURE, png(0xFF112233)).get(5, TimeUnit.SECONDS).pack(); + BlockingQueue taken = new LinkedBlockingQueue<>(); + edits.external().follow(TEXTURE, pack); + edits.external().addListener(TEXTURE, pack, (saved, failure) -> taken.add(failure != null ? failure : saved)); + edits.close(); + + Files.write(pack.resolve(TEXTURE), png(0xFF445566)); + assertNull(taken.poll(1500, TimeUnit.MILLISECONDS), "a save after the project closed is not taken"); + // On Windows a watch of the texture's folder would keep the pack from being moved. + Files.move(pack, pack.resolveSibling("Moved")); + assertFalse(Files.exists(pack)); + } + @Test void aTextureIsWholeOnceItEndsWithItsClosingChunk() throws Exception { byte[] whole = png(0xFF112233); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/DirectoryChainTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/DirectoryChainTest.java index 2ddf66967..c679c116a 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/DirectoryChainTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/DirectoryChainTest.java @@ -1,9 +1,9 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView.lazyFileTree; +import com.github.minecraft_ta.totalDebugCompanion.util.FileWatch; import com.github.minecraft_ta.totalDebugCompanion.testui.UiTest; import com.github.minecraft_ta.totalDebugCompanion.ui.theme.CompanionTheme; import com.github.minecraft_ta.totalDebugCompanion.ui.theme.ThemeManager; -import com.github.minecraft_ta.totalDebugCompanion.util.FileUtils; import com.github.minecraft_ta.totalDebugCompanion.ui.ContextMenus; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -24,8 +24,6 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; @@ -415,15 +413,11 @@ class Cycle extends DirectoryTreeItem { } private static int subscriptions() { - synchronized (FileUtils.class) { + FileWatch watch = FileWatch.shared(); + synchronized (watch) { try { - var field = FileUtils.class.getDeclaredField("registrations"); field.setAccessible(true); - int count = 0; - for (Object value : ((Map) field.get(null)).values()) { - var listeners = value.getClass().getDeclaredField("listeners"); listeners.setAccessible(true); - count += ((Set) listeners.get(value)).size(); - } - return count; + var field = FileWatch.class.getDeclaredField("watched"); field.setAccessible(true); + return ((List) field.get(watch)).size(); } catch (ReflectiveOperationException failure) { throw new AssertionError(failure); } } } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/DirectoryWatcherTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/DirectoryWatcherTest.java deleted file mode 100644 index 8e6eafe11..000000000 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/DirectoryWatcherTest.java +++ /dev/null @@ -1,201 +0,0 @@ -package com.github.minecraft_ta.totalDebugCompanion.util; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.WatchKey; -import java.nio.file.StandardWatchEventKinds; -import javax.swing.SwingUtilities; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicBoolean; -import static org.junit.jupiter.api.Assertions.*; - -class DirectoryWatcherTest { - @TempDir Path directory; - - @Test void lastSubscriberCanLeaveOnEdtWhileRebindingAndTheStaleKeyIsCancelled() throws Exception { - try (var held = new HeldRebind(Files.createDirectory(directory.resolve("held")))) { - WatchKey key = held.registered.get(5, TimeUnit.SECONDS); - var left = new CompletableFuture(); - SwingUtilities.invokeLater(() -> { - try { held.stop.run(); left.complete(null); } - catch (Throwable failure) { left.completeExceptionally(failure); } - }); - left.get(2, TimeUnit.SECONDS); - assertFalse(held.release.isDone(), "Unsubscribe must finish before native registration is released"); - held.release.complete(null); - FileUtils.withPausedDirectoryWatchers(List.of(directory), () -> assertFalse(key.isValid(), - "A registration whose final subscriber left must not install a live key")); - } - } - - @Test void renamePauseDrainsAnInFlightRebindBeforeMutatingTheDirectory() throws Exception { - Path folder = Files.createDirectory(directory.resolve("./held")); - Path watched = folder.toRealPath(); - try (var held = new HeldRebind(folder)) { - WatchKey key = held.registered.get(5, TimeUnit.SECONDS); - var moved = new AtomicBoolean(); - var pause = CompletableFuture.runAsync(() -> { - try { - FileUtils.withPausedDirectoryWatchers(List.of(folder), () -> { - assertFalse(key.isValid()); - Files.move(folder, directory.resolve("moved")); - moved.set(true); - }); - } catch (IOException failure) { throw new AssertionError(failure); } - }); - var pausedField = FileUtils.class.getDeclaredField("pausedRoots"); - pausedField.setAccessible(true); - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - boolean paused; - do { - synchronized (FileUtils.class) { paused = ((Map) pausedField.get(null)).containsKey(watched); } - if (!paused) Thread.sleep(5); - } while (!paused && System.nanoTime() < deadline); - assertTrue(paused); - assertFalse(moved.get(), "The mutation must await cancellation of the in-flight watch handle"); - held.release.complete(null); - pause.get(5, TimeUnit.SECONDS); - assertTrue(Files.isDirectory(directory.resolve("moved"))); - } - } - - /** Hold the provider's second successful register call with a real native key already allocated. */ - private static final class HeldRebind implements AutoCloseable { - final CompletableFuture registered = new CompletableFuture<>(); - final CompletableFuture release = new CompletableFuture<>(); - final Runnable stop; - - HeldRebind(Path folder) throws Exception { - Path real = folder.toRealPath(); - var calls = new AtomicInteger(); - stop = FileUtils.startNewDirectoryWatcher(real, () -> { }, (path, service) -> { - WatchKey key = path.register(service, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE); - if (calls.incrementAndGet() > 1) { - registered.complete(key); - try { release.get(10, TimeUnit.SECONDS); } - catch (Exception failure) { key.cancel(); throw new IOException(failure); } - } - return key; - }); - Files.delete(real); - Files.createDirectory(real); - } - - @Override public void close() { - release.complete(null); - stop.run(); - } - } - - @Test void existingSubscriberRecoversAfterItsDirectoryIsDeletedAndRecreated() throws Exception { - Path folder = Files.createDirectory(directory.resolve("scripts")); - var changed = new AtomicReference<>(new CountDownLatch(1)); - Runnable stop = FileUtils.startNewDirectoryWatcher(folder, () -> changed.get().countDown()); - try { - for (int attempt = 0; attempt < 2; attempt++) { - changed.set(new CountDownLatch(1)); - Files.delete(folder); - assertTrue(changed.get().await(5, TimeUnit.SECONDS), "Deletion must invalidate the old watch"); - changed.set(new CountDownLatch(1)); - Files.createDirectory(folder); - assertTrue(changed.get().await(5, TimeUnit.SECONDS), "The existing subscriber must be notified when watching resumes"); - changed.set(new CountDownLatch(1)); - Path created = Files.writeString(folder.resolve("new.tdscript"), "return 1;"); - assertTrue(changed.get().await(5, TimeUnit.SECONDS), "The replacement directory must deliver subsequent changes"); - changed.set(new CountDownLatch(1)); - Files.delete(created); - assertTrue(changed.get().await(5, TimeUnit.SECONDS)); - } - } finally { stop.run(); } - } - - @Test void aFailedOperationRestoresWatchingAndRefreshesChangesMadeWhilePaused() throws Exception { - Path folder = Files.createDirectory(directory.resolve("folder")); - var changed = new AtomicReference<>(new CountDownLatch(1)); - Runnable stop = FileUtils.startNewDirectoryWatcher(folder, () -> changed.get().countDown()); - try { - assertThrows(IOException.class, () -> FileUtils.withPausedDirectoryWatchers(List.of(folder), () -> { - Files.writeString(folder.resolve("during"), ""); - throw new IOException("Operation failed"); - })); - assertTrue(changed.get().await(5, TimeUnit.SECONDS)); - changed.set(new CountDownLatch(1)); - Files.writeString(folder.resolve("after"), ""); - assertTrue(changed.get().await(5, TimeUnit.SECONDS)); - } finally { stop.run(); } - } - - @Test void nestedScopesAndNewSubscriptionsDoNotReopenHandlesDuringMove() throws Exception { - Path parent = Files.createDirectories(directory.resolve("parent/child")).getParent(); - Path child = parent.resolve("child"); - Runnable first = FileUtils.startNewDirectoryWatcher(child, () -> {}); - var second = new AtomicReference(() -> {}); - try { - FileUtils.withPausedDirectoryWatchers(List.of(parent), () -> { - FileUtils.withPausedDirectoryWatchers(List.of(parent), () -> - second.set(FileUtils.startNewDirectoryWatcher(child, () -> {}))); - Files.move(parent, directory.resolve("moved")); - }); - assertTrue(Files.isDirectory(directory.resolve("moved/child"))); - } finally { first.run(); second.get().run(); } - } - - @Test void aReplacementDirectoryGetsANewWatchEvenWhileAnOldSubscriberRemains() throws Exception { - Path folder = Files.createDirectory(directory.resolve("replaced")); - Runnable old = FileUtils.startNewDirectoryWatcher(folder, () -> {}); - Runnable replacement = () -> {}; - try { - Files.delete(folder); - Files.createDirectory(folder); - var changed = new CountDownLatch(1); - replacement = FileUtils.startNewDirectoryWatcher(folder, changed::countDown); - old.run(); - Files.writeString(folder.resolve("new"), ""); - assertTrue(changed.await(5, TimeUnit.SECONDS)); - } finally { old.run(); replacement.run(); } - } - - @Test void aliasesShareOwnershipAcrossUnsubscribeAndPause() throws Exception { - Path real = Files.createDirectory(directory.resolve("real")); - Path alias = directory.resolve("alias"); - if (System.getProperty("os.name").startsWith("Windows")) { - var process = new ProcessBuilder("cmd", "/c", "mklink", "/J", alias.toString(), real.toString()).redirectErrorStream(true).start(); - String output = new String(process.getInputStream().readAllBytes()); - assertEquals(0, process.waitFor(), output); - } else Files.createSymbolicLink(alias, real); - var changed = new AtomicReference<>(new CountDownLatch(1)); - Runnable first = FileUtils.startNewDirectoryWatcher(real, () -> {}); - Runnable second = FileUtils.startNewDirectoryWatcher(alias, () -> changed.get().countDown()); - try { - first.run(); - Files.writeString(real.resolve("before"), ""); - assertTrue(changed.get().await(5, TimeUnit.SECONDS)); - FileUtils.withPausedDirectoryWatchers(List.of(alias), () -> Files.writeString(real.resolve("during"), "")); - changed.set(new CountDownLatch(1)); - Files.writeString(real.resolve("after"), ""); - assertTrue(changed.get().await(5, TimeUnit.SECONDS)); - } finally { first.run(); second.run(); Files.delete(alias); } - } - - @Test void unrelatedFoldersKeepReceivingChangesDuringAnOperation() throws Exception { - Path other = Files.createDirectory(directory.resolve("other")); - var changed = new CountDownLatch(1); - Runnable stop = FileUtils.startNewDirectoryWatcher(other, changed::countDown); - try { - FileUtils.withPausedDirectoryWatchers(List.of(Files.createDirectory(directory.resolve("affected"))), () -> { - Files.writeString(other.resolve("new"), ""); - try { assertTrue(changed.await(5, TimeUnit.SECONDS)); } - catch (InterruptedException failure) { throw new IOException(failure); } - }); - } finally { stop.run(); } - } -} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java new file mode 100644 index 000000000..4c0227602 --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java @@ -0,0 +1,167 @@ +package com.github.minecraft_ta.totalDebugCompanion.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The one watcher: listings told of entries only, and Companion's own moves of watched folders. */ +class FileWatchTest { + @TempDir Path directory; + + @Test + void aWatchedFolderAndOneInsideItCanBeMovedWhileTheWatchIsPaused() throws Exception { + Path parent = Files.createDirectories(this.directory.resolve("parent/child")).getParent(); + Runnable onParent = FileWatch.shared().watchEntries(parent, () -> { }); + Runnable onChild = FileWatch.shared().watchEntries(parent.resolve("child"), () -> { }); + try { + FileWatch.shared().pausing(List.of(parent), () -> Files.move(parent, this.directory.resolve("moved"))); + assertTrue(Files.isDirectory(this.directory.resolve("moved/child"))); + } finally { + onParent.run(); + onChild.run(); + } + } + + @Test + void aFollowerAddedWhileAPauseLastsWatchesNothingThereUntilItEnds() throws Exception { + Path parent = Files.createDirectories(this.directory.resolve("parent/child")).getParent(); + AtomicReference added = new AtomicReference<>(() -> { }); + try { + FileWatch.shared().pausing(List.of(parent), () -> { + FileWatch.shared().pausing(List.of(parent), () -> added.set(FileWatch.shared().watchEntries(parent.resolve("child"), () -> { }))); + Files.move(parent, this.directory.resolve("moved")); + }); + assertTrue(Files.isDirectory(this.directory.resolve("moved/child"))); + } finally { + added.get().run(); + } + } + + @Test + void aFailedOperationWatchesAgainAndTellsWhatItChanged() throws Exception { + Path folder = Files.createDirectory(this.directory.resolve("folder")); + AtomicReference changed = new AtomicReference<>(new CountDownLatch(1)); + Runnable stop = FileWatch.shared().watchEntries(folder, () -> changed.get().countDown()); + try { + assertThrows(IOException.class, () -> FileWatch.shared().pausing(List.of(folder), () -> { + Files.writeString(folder.resolve("during"), ""); + throw new IOException("the operation failed"); + })); + assertTrue(changed.get().await(5, TimeUnit.SECONDS), "what the operation changed is told"); + changed.set(new CountDownLatch(1)); + Files.writeString(folder.resolve("after"), ""); + assertTrue(changed.get().await(5, TimeUnit.SECONDS), "the folder is watched again"); + } finally { + stop.run(); + } + } + + @Test + void otherFoldersAreToldWhileAPauseLasts() throws Exception { + Path other = Files.createDirectory(this.directory.resolve("other")); + CountDownLatch changed = new CountDownLatch(1); + Runnable stop = FileWatch.shared().watchEntries(other, changed::countDown); + try { + FileWatch.shared().pausing(List.of(Files.createDirectory(this.directory.resolve("affected"))), () -> { + Files.writeString(other.resolve("new"), ""); + try { + assertTrue(changed.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException interrupted) { + throw new IOException(interrupted); + } + }); + } finally { + stop.run(); + } + } + + @Test + void aFolderReachedThroughALinkIsPausedAndWatchedAgain() throws Exception { + Path real = Files.createDirectory(this.directory.resolve("real")); + Path alias = this.directory.resolve("alias"); + Process link = new ProcessBuilder("cmd", "/c", "mklink", "/J", alias.toString(), real.toString()).redirectErrorStream(true).start(); + String output = new String(link.getInputStream().readAllBytes()); + assertEquals(0, link.waitFor(), output); + AtomicReference changed = new AtomicReference<>(new CountDownLatch(1)); + Runnable first = FileWatch.shared().watchEntries(real, () -> { }); + Runnable second = FileWatch.shared().watchEntries(alias, () -> changed.get().countDown()); + try { + first.run(); + Files.writeString(real.resolve("before"), ""); + assertTrue(changed.get().await(5, TimeUnit.SECONDS), "the link's follower keeps the shared watch"); + FileWatch.shared().pausing(List.of(alias), () -> Files.writeString(real.resolve("during"), "")); + changed.set(new CountDownLatch(1)); + Files.writeString(real.resolve("after"), ""); + assertTrue(changed.get().await(5, TimeUnit.SECONDS), "watched again after the pause"); + } finally { + first.run(); + second.run(); + Files.delete(alias); + } + } + + @Test + void aListingIsToldOfEntriesCreatedAndRemovedButNotOfWrites() throws Exception { + Path folder = Files.createDirectory(this.directory.resolve("scripts")); + Path file = Files.writeString(folder.resolve("existing.tdscript"), "return 1;"); + AtomicInteger told = new AtomicInteger(); + Runnable stop = FileWatch.shared().watchEntries(folder, told::incrementAndGet); + try { + Thread.sleep(300); + Files.writeString(file, "return 2;"); + Thread.sleep(700); + assertEquals(0, told.get(), "a write of an entry is not a change of the listing"); + Path created = Files.writeString(folder.resolve("new.tdscript"), "return 3;"); + await(() -> told.get() >= 1); + int afterCreate = told.get(); + Files.delete(created); + await(() -> told.get() > afterCreate); + } finally { + stop.run(); + } + } + + @Test + void aListingOfAFolderDeletedAndMadeAgainIsToldAndWatchedAgain() throws Exception { + Path folder = Files.createDirectory(this.directory.resolve("scripts")); + AtomicReference changed = new AtomicReference<>(new CountDownLatch(1)); + Runnable stop = FileWatch.shared().watchEntries(folder, () -> changed.get().countDown()); + try { + for (int attempt = 0; attempt < 2; attempt++) { + changed.set(new CountDownLatch(1)); + Files.delete(folder); + assertTrue(changed.get().await(5, TimeUnit.SECONDS), "the folder's removal is told"); + changed.set(new CountDownLatch(1)); + Files.createDirectory(folder); + assertTrue(changed.get().await(5, TimeUnit.SECONDS), "the folder made again is told"); + changed.set(new CountDownLatch(1)); + Path created = Files.writeString(folder.resolve("new.tdscript"), "return 1;"); + assertTrue(changed.get().await(5, TimeUnit.SECONDS), "the folder made again is watched"); + changed.set(new CountDownLatch(1)); + Files.delete(created); + assertTrue(changed.get().await(5, TimeUnit.SECONDS)); + } + } finally { + stop.run(); + } + } + + private static void await(BooleanSupplier condition) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!condition.getAsBoolean() && System.nanoTime() < deadline) Thread.sleep(10); + assertTrue(condition.getAsBoolean(), "timed out"); + } +} diff --git a/docs/SYSTEMS.md b/docs/SYSTEMS.md index 2d89b7089..1b7ae1767 100644 --- a/docs/SYSTEMS.md +++ b/docs/SYSTEMS.md @@ -99,13 +99,13 @@ reading.moveTo(otherFile); // a folder that moves, as the current - **One `FileWatch` for the application** does the watching, with one thread. A registration lives until its reading closes or stops watching (`watch(false)`); a reading that does not watch keeps its value and reads only when its owner asks (`readNow`). - **A folder that does not exist yet** is watched through its nearest existing ancestor, and watched itself once it appears, as a game's folder before it first ran; one removed while watched is watched that way again. - **Where a folder cannot be watched at all**, as without file watching or on some network drives, it is tried again after 1 and 5 seconds and every 30 seconds after, and the reading reads each time. -- **Events are filtered by name**, so `latest.log` growing beside `options.txt` does not read `options.txt`. +- **Events are filtered by name**, so `latest.log` growing beside `options.txt` does not read `options.txt`. A listing of a folder, as the Project tree's, is told only of entries created or removed (`watchEntries`). - **A read that fails is retried** after 1, 5 and 30 seconds, as a file written in place can be read half written; the next read that succeeds is told, since a page may show the failure. Overflowing events read once. - **Folders that move are the owner's.** The current world follows `playing` and moves its reading to the world the game plays, or with the game closed, the last played. -- **Windows renames.** On Windows a folder cannot be renamed or deleted while a folder inside it is watched. Registrations sit on the smallest folders that answer the question. The current world's folder and its `datapacks` are watched only while the connected game plays it, which the game tells once it let go of the world, so its Delete World works; a world the game does not play is looked at again when the game connects or plays another world, when a world of `saves` is created or removed, and after Companion changed its datapacks. A game Companion is not connected to has its worlds read then, not watched. `FileUtils`' pause around Companion's own renames joins `FileWatch` when its watcher moves there. +- **Windows renames.** On Windows a folder cannot be renamed or deleted while a folder inside it is watched. Registrations sit on the smallest folders that answer the question. The current world's folder and its `datapacks` are watched only while the connected game plays it, which the game tells once it let go of the world, so its Delete World works; a world the game does not play is looked at again when the game connects or plays another world, when a world of `saves` is created or removed, and after Companion changed its datapacks. A game Companion is not connected to has its worlds read then, not watched. Companion's own moves of watched folders, as of script folders, run in `FileWatch.pausing`, which lets go of every watch on or inside them for the move and watches them again after. - **A reading's value is a domain value with a meaningful equality**, such as parsed assignments or a list of names; the reader supplies it. -- **What each reading holds decides what fires.** The logs' reading holds the names of the logs and crash reports, not their sizes, so `latest.log` growing does not fire; the Logs page reads the log a row shows when the row is shown. A configuration folder's reading ignores Companion's own `*.totaldebug-original` files. -- **Readings replace every "read whenever shown":** `options.txt`, configuration files, logs and crash reports, the current world's `level.dat` and icon, the `saves` and `resourcepacks` folders. +- **What each reading holds decides what fires**, such as parsed assignments rather than the file's bytes. +- **Readings replace "read whenever shown" where others than the page need the value:** `options.txt` and the current world, which the Project tree, the tab strip and the change pipeline use too. The Logs page, the configuration pages and the resource packs listing are the only readers of their files, and the game writes its logs the whole time it runs: they read whenever they are shown (`readsWhenShown`), which a reading would only repeat with a watch. Decided on 2026-09-30, when the watchers moved onto `FileWatch`. - **Caches are allowed where keyed by what they cache**, a file's size and time or its hash, as `TextureThumbnails`, `CatalogIcons` and the parsed logs are. A cache is never the truth another part reads. **Adopting a file is a write, not a reading.** A texture saved in an external editor is taken into the pack: `ExternalEdits` registers with `FileWatch`, keeps its baseline and checks that the image is complete, and its adoption runs on the project's write queue through the pipeline. Before it records a change or asks for a reload, the adoption compares the saved content with what the pack already holds, by hash, and does nothing when they are the same: a notification of content Companion itself wrote, or a second notification of one save, must not record or reload again. Deduplication happens before side effects, in the owner, never after them in a page. The watchers of `FileUtils`, `ExternalEdits` and `KeyAssignments` become registrations. @@ -135,7 +135,7 @@ this.loader = new PageLoader<>(this::read, this::show, this::fail) **Navigation never reads files.** It shows a page, and passes a selection to it. A navigation that carries a new request to the game, as inspecting a subject again, asks the owner of that request. `InspectionSession`'s timer, which polls the game for live values, stays until live channels (C2) push them, and is listed in the architecture test. -This replaces `ShownUpdates`, the three modes, the subscriptions pages hold themselves, the reads in constructors, the `refresh()` calls on navigation and in the `model/*View` classes, and `CompanionUi.catalogChanged` and `changesRecorded`. +This replaces `ShownUpdates`, the modes `whenShown` and `waitsWhileHidden`, the subscriptions pages hold themselves, the reads in constructors, the `refresh()` calls on navigation and in the `model/*View` classes, and `CompanionUi.catalogChanged` and `changesRecorded`. ## 4. Threads @@ -204,8 +204,8 @@ PRs on 1.21.1, stacked, each reviewed until clean. A shared mechanism comes with | 3 (#114) | `Workers` and `Strand`, `FileWatch` and `FileReading`, with `KeyAssignments` as first user | `KeyAssignments`' watcher, scheduler and counters | | 4 (#115) | The current world as a `FileReading` that follows `playing`, with the World page and the tree | `WorldReadings`, the World page's read whenever shown | | 5 | The owners still telling through their own listeners on signals: `GameLocation`, `ItemIconService`, `RuntimeIndexService` | `GameLocation.Change` and the three listener lists | -| 6 | The remaining readings (`GameLogs`, the configuration folders, `saves` and `resourcepacks`) with their pages; `FileUtils`' watcher onto `FileWatch`; `ExternalEdits` as an adoption | the watchers and schedulers of `FileUtils` and `ExternalEdits`, the `readsWhenShown` mode | -| 7 | All remaining pages, the Project tree and the tab strip; no file checks on the Swing thread | `ShownUpdates`, the old modes, the pages' own subscriptions, the reads in constructors, the navigation refreshes, the `CompanionUi` relays, `ChangeRecord.observed` from page reads | +| 6 | The last watchers onto `FileWatch`: the Project tree's folders, told of entries only, and `ExternalEdits`, whose settle runs on the timer; `FileUtils`' pause for Companion's own moves becomes `FileWatch.pausing` | `FileUtils`, the watchers and schedulers of `ExternalEdits` | +| 7 | All remaining pages, the Project tree and the tab strip, the logs, configuration and resource packs pages reading whenever shown; no file checks on the Swing thread | `ShownUpdates`, `whenShown` and `waitsWhileHidden`, the pages' own subscriptions, the reads in constructors, the navigation refreshes, the `CompanionUi` relays, `ChangeRecord.observed` from page reads | | 8 | The current project as state; connection numbers for waiting requests; the pipeline owning the write queue; the remaining executors onto `Workers` | `ConfigChanges`' executor, the scope checks, the UI classes' executors | After the messages, A4 continues with categories registering their pages and Modpack rows. Splitting `CompanionApplication` (the game connection and the MCP server into their own classes) and the mod's `CompanionAppClient` (launching Companion) is easier then and is decided at that point. From 330db57a8636b3fe8f25668ea8bc8287f85d3db3 Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:32:34 +0200 Subject: [PATCH 2/4] Guard junction tests and discard superseded external saves --- .../totalDebugCompanion/pack/ExternalEdits.java | 10 +++++++--- .../totalDebugCompanion/util/FileWatchTest.java | 3 +++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEdits.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEdits.java index cd8a20b1c..2bf3e256e 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEdits.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEdits.java @@ -49,6 +49,8 @@ private static final class Followed { final List> 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; @@ -123,12 +125,14 @@ private synchronized void stopFollowing(Path file) { private synchronized void settle(Followed file) { if (this.closed) return; if (file.pending != null) file.pending.cancel(false); - file.pending = Workers.later(SETTLE_MILLIS, this.strand, () -> take(file)); + 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) { - if (this.closed) return; + // Another write came since, whose own settle takes the file. + if (this.closed || write != file.writes) return; } Path location = file.pack.resolve(file.path); byte[] seen; diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java index 4c0227602..aa6ee1490 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java @@ -1,6 +1,8 @@ package com.github.minecraft_ta.totalDebugCompanion.util; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.TempDir; import java.io.IOException; @@ -89,6 +91,7 @@ void otherFoldersAreToldWhileAPauseLasts() throws Exception { } @Test + @EnabledOnOs(OS.WINDOWS) void aFolderReachedThroughALinkIsPausedAndWatchedAgain() throws Exception { Path real = Files.createDirectory(this.directory.resolve("real")); Path alias = this.directory.resolve("alias"); From e5d2534b659dc996e8d6aab172c0e86d741d2031 Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:56:16 +0200 Subject: [PATCH 3/4] Keep watcher callbacks paused through nested file operations --- .../totalDebugCompanion/util/FileWatch.java | 23 +++++++++++++++---- .../util/FileWatchTest.java | 12 +++++++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java index 253de5dd5..51b8d5030 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java @@ -50,6 +50,8 @@ private static final class Watched { Path at; /** The watch of {@link #at}, which a folder reached through another path, as a link, shares. */ WatchKey key; + /** The real folder whose pause holds this registration, even after that folder was moved. */ + Path pausedAt; ScheduledFuture retry; boolean closed; @@ -120,6 +122,7 @@ public void pausing(List roots, Operation operation) throws IOException { if (followed.key != entry.getValue()) continue; followed.key = null; followed.at = null; + followed.pausedAt = entry.getKey(); } } } @@ -131,9 +134,10 @@ public void pausing(List roots, Operation operation) throws IOException { real.forEach(root -> this.paused.computeIfPresent(root, (ignored, count) -> count == 1 ? null : count - 1)); for (Watched followed : this.watched) { if (followed.key != null) continue; + if (followed.pausedAt != null && paused(followed.pausedAt)) continue; if (followed.retry != null) followed.retry.cancel(false); place(followed, 0); - tell.add(followed.changed); + if (followed.pausedAt == null) tell.add(followed.changed); } } // Also where the operation failed: it may have changed something before. @@ -158,6 +162,7 @@ private Runnable watch(Watched followed) { /** Watches {@code followed}'s folder or its nearest existing ancestor, or tries again later. Under the lock. */ private void place(Watched followed, int attempt) { + followed.pausedAt = null; WatchKey previous = followed.key; List passed = new ArrayList<>(); Path at; @@ -165,7 +170,7 @@ private void place(Watched followed, int attempt) { while (true) { at = followed.folder; while (at != null && !Files.isDirectory(at)) at = at.getParent(); - key = at == null ? null : register(at); + key = at == null ? null : register(at, followed); if (key == null) { at = null; break; @@ -185,12 +190,15 @@ private void place(Watched followed, int attempt) { } /** The watch of {@code folder}, shared with every path that leads to it; null where it cannot be watched. */ - private WatchKey register(Path folder) { + private WatchKey register(Path folder, Watched followed) { if (this.service == null) return null; try { Path real = folder.toRealPath(); // Watched again once the operation that pauses it ended. - if (paused(real)) return null; + if (paused(real)) { + followed.pausedAt = real; + return null; + } WatchKey key = this.keys.get(real); if (key != null && key.isValid()) return key; key = real.register(this.service, StandardWatchEventKinds.ENTRY_CREATE, @@ -226,9 +234,14 @@ private void retryLater(Watched followed, int attempt) { followed.retry = Workers.later(delay, Workers.files(), () -> { synchronized (this) { if (followed.closed) return; + if (followed.pausedAt != null && paused(followed.pausedAt)) { + retryLater(followed, attempt + 1); + return; + } place(followed, attempt + 1); + if (followed.pausedAt != null) return; } - followed.changed.run(); + tell(followed.changed); }); } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java index aa6ee1490..a654488d9 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java @@ -16,6 +16,7 @@ import java.util.function.BooleanSupplier; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -41,14 +42,23 @@ void aWatchedFolderAndOneInsideItCanBeMovedWhileTheWatchIsPaused() throws Except void aFollowerAddedWhileAPauseLastsWatchesNothingThereUntilItEnds() throws Exception { Path parent = Files.createDirectories(this.directory.resolve("parent/child")).getParent(); AtomicReference added = new AtomicReference<>(() -> { }); + CountDownLatch told = new CountDownLatch(1); + Runnable original = FileWatch.shared().watchEntries(parent, told::countDown); try { FileWatch.shared().pausing(List.of(parent), () -> { - FileWatch.shared().pausing(List.of(parent), () -> added.set(FileWatch.shared().watchEntries(parent.resolve("child"), () -> { }))); + FileWatch.shared().pausing(List.of(parent), () -> added.set(FileWatch.shared().watchEntries(parent.resolve("child"), told::countDown))); + try { + assertFalse(told.await(1_250, TimeUnit.MILLISECONDS), "neither the inner pause nor a retry tells before the outer operation ends"); + } catch (InterruptedException interrupted) { + throw new IOException(interrupted); + } Files.move(parent, this.directory.resolve("moved")); }); + assertTrue(told.await(5, TimeUnit.SECONDS), "the completed outer operation tells its followers"); assertTrue(Files.isDirectory(this.directory.resolve("moved/child"))); } finally { added.get().run(); + original.run(); } } From 30cee53556099ce049858e0f21d9de1a9904fac0 Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:11:04 +0200 Subject: [PATCH 4/4] Limit watcher recovery and revalidate external save adoption --- .../pack/ExternalEdits.java | 7 +++++- .../totalDebugCompanion/util/FileWatch.java | 21 +++++++++++++++--- .../util/FileWatchTest.java | 22 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEdits.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEdits.java index 2bf3e256e..a208589d1 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEdits.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEdits.java @@ -145,7 +145,12 @@ private void take(Followed file, long write) { // 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 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)); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java index 51b8d5030..cb67589ea 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java @@ -92,7 +92,7 @@ public static FileWatch shared() { * removed, and whenever events may have been lost; returns what stops it. */ public Runnable watch(Path folder, Predicate names, Runnable changed) { - return watch(new Watched(folder.toAbsolutePath().normalize(), Objects.requireNonNull(names, "names"), false, + return watch(new Watched(followedFolder(folder), Objects.requireNonNull(names, "names"), false, Objects.requireNonNull(changed, "changed"))); } @@ -101,7 +101,22 @@ public Runnable watch(Path folder, Predicate names, Runnable changed) { * whenever events may have been lost; returns what stops it. For a listing of the folder. */ public Runnable watchEntries(Path folder, Runnable changed) { - return watch(new Watched(folder.toAbsolutePath().normalize(), name -> true, true, Objects.requireNonNull(changed, "changed"))); + return watch(new Watched(followedFolder(folder), name -> true, true, Objects.requireNonNull(changed, "changed"))); + } + + /** Keeps a linked folder's real target when it disappears, so watching its parent can follow its return. */ + private static Path followedFolder(Path folder) { + Path absolute = folder.toAbsolutePath().normalize(); + Path existing = absolute; + while (existing != null && !Files.isDirectory(existing)) existing = existing.getParent(); + if (existing != null) { + try { + return existing.toRealPath().resolve(existing.relativize(absolute)); + } catch (IOException | RuntimeException unavailable) { + // The ordinary registration retries paths it cannot resolve yet. + } + } + return absolute; } /** @@ -134,7 +149,7 @@ public void pausing(List roots, Operation operation) throws IOException { real.forEach(root -> this.paused.computeIfPresent(root, (ignored, count) -> count == 1 ? null : count - 1)); for (Watched followed : this.watched) { if (followed.key != null) continue; - if (followed.pausedAt != null && paused(followed.pausedAt)) continue; + if (followed.pausedAt == null || paused(followed.pausedAt)) continue; if (followed.retry != null) followed.retry.cancel(false); place(followed, 0); if (followed.pausedAt == null) tell.add(followed.changed); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java index a654488d9..d96679bf4 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java @@ -100,6 +100,28 @@ void otherFoldersAreToldWhileAPauseLasts() throws Exception { } } + @Test + @EnabledOnOs(OS.WINDOWS) + void aLinkedFoldersTargetIsFollowedAfterItIsRecreated() throws Exception { + Path real = Files.createDirectories(this.directory.resolve("targets/real")); + Path alias = Files.createDirectory(this.directory.resolve("links")).resolve("alias"); + Process link = new ProcessBuilder("cmd", "/c", "mklink", "/J", alias.toString(), real.toString()).redirectErrorStream(true).start(); + String output = new String(link.getInputStream().readAllBytes()); + assertEquals(0, link.waitFor(), output); + AtomicReference changed = new AtomicReference<>(new CountDownLatch(1)); + Runnable stop = FileWatch.shared().watchEntries(alias, () -> changed.get().countDown()); + try { + FileWatch.shared().pausing(List.of(real), () -> Files.delete(real)); + assertTrue(changed.get().await(5, TimeUnit.SECONDS)); + changed.set(new CountDownLatch(1)); + Files.createDirectory(real); + assertTrue(changed.get().await(5, TimeUnit.SECONDS), "the target's parent observes its return even though the link's parent does not change"); + } finally { + stop.run(); + Files.delete(alias); + } + } + @Test @EnabledOnOs(OS.WINDOWS) void aFolderReachedThroughALinkIsPausedAndWatchedAgain() throws Exception {