diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4065dd75..5f0be857 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -6,15 +6,23 @@ on: pull_request: branches: - main + - improved-macos-support-main jobs: test: strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: + - image: ubuntu-latest + - image: macos-latest + mac-backend: jdk + - image: macos-latest + mac-backend: fsevents + - image: windows-latest jdk: [11, 17, 21] + fail-fast: false - runs-on: ${{ matrix.os }} + runs-on: ${{ matrix.os.image }} steps: - uses: actions/checkout@v4 - run: echo " " >> pom.xml # make sure the cache is slightly different for these runners @@ -26,7 +34,7 @@ jobs: cache: 'maven' - name: test - run: mvn -B clean test + run: mvn -B clean test "-Dwatch.mac.backend=${{ matrix.os.mac-backend }}" env: DELAY_FACTOR: 3 diff --git a/README.md b/README.md index e026304d..a0fdb260 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,18 @@ [![javadoc](https://javadoc.io/badge2/engineering.swat/java-watch/docs.svg?style=flat-square)](https://javadoc.io/doc/engineering.swat/java-watch) [![Codecov](https://img.shields.io/codecov/c/github/SWAT-engineering/java-watch?style=flat-square)](https://codecov.io/gh/SWAT-engineering/java-watch) -a java file watcher that works across platforms and supports recursion, single file watches, and tries to make sure no events are missed. Where possible it uses Java's NIO WatchService. +A Java file watcher that works across platforms and supports recursion, single file watches, and tries to make sure no events are missed. ## Features Features: - monitor a single file (or directory) for changes -- monitor a directory for changes to its direct descendants +- monitor a directory for changes to its direct children - monitor a directory for changes for all its descendants (aka recursive directory watch) +- backends supported: + - the JDK [`WatchService`](https://docs.oracle.com/javase/8/docs/api/java/nio/file/WatchService.html) API on any platform + - the native [FSEvents](https://developer.apple.com/documentation/coreservices/file_system_events) API on macOS - edge cases dealt with: - recursive watches will also continue in new directories - multiple watches for the same directory are merged to avoid overloading the kernel @@ -21,7 +24,6 @@ Features: Planned features: -- Avoid poll based watcher in macOS/OSX that only detects changes every 2 seconds (see [#4](https://github.com/SWAT-engineering/java-watch/issues/4)) - Support single file watches natively in linux (see [#11](https://github.com/SWAT-engineering/java-watch/issues/11)) - Monitor only specific events (such as only CREATE events) @@ -58,6 +60,14 @@ try(var active = watcherSetup.start()) { // no new events will be scheduled on the threadpool ``` +## Backends + +On all platforms except macOS, the library internally uses the JDK default implementation of the Java NIO [`WatchService`](https://docs.oracle.com/javase/8/docs/api/java/nio/file/WatchService.html) API. + +On macOS, the library internally uses our custom `WatchService` implementation based on macOS's native [FSEvents](https://developer.apple.com/documentation/coreservices/file_system_events) API. +Generally, it offers better performance than the JDK default implementation (because the latter uses a polling loop to detect changes at fixed time intervals). +To force the library to use the JDK default implementation on macOS, set system property `engineering.swat.java-watch.mac` to `jdk`. + ## Related work Before starting this library, we wanted to use existing libraries, but they all lacked proper support for recursive file watches, single file watches or lacked configurability. This library now has a growing collection of tests and a small API that should allow for future improvements without breaking compatibility. diff --git a/pom.xml b/pom.xml index e3c2869d..79da7669 100644 --- a/pom.xml +++ b/pom.xml @@ -74,8 +74,10 @@ 3.49.2 5.12.2 2.24.3 + 5.16.0 11 11 + fsevents @@ -103,6 +105,9 @@ org.apache.maven.plugins maven-surefire-plugin 3.5.3 + + @{argLine} -Dengineering.swat.java-watch.mac=${watch.mac.backend} + org.jacoco @@ -223,6 +228,16 @@ ${log4j.version} test + + net.java.dev.jna + jna + ${jna.version} + + + net.java.dev.jna + jna-platform + ${jna.version} + diff --git a/src/main/checkerframework/nio-file.astub b/src/main/checkerframework/nio-file.astub new file mode 100644 index 00000000..12185ba8 --- /dev/null +++ b/src/main/checkerframework/nio-file.astub @@ -0,0 +1,14 @@ +package java.nio.file; + +import org.checkerframework.checker.nullness.qual.Nullable; + +public interface WatchService { + @Nullable WatchKey poll(); + + @Nullable WatchKey poll(long timeout, TimeUnit unit) + throws InterruptedException; +} + +public interface WatchEvent { + @Nullable T context(); +} diff --git a/src/main/java/engineering/swat/watch/impl/jdk/JDKPoller.java b/src/main/java/engineering/swat/watch/impl/jdk/JDKPoller.java index ae12cd8c..a431f413 100644 --- a/src/main/java/engineering/swat/watch/impl/jdk/JDKPoller.java +++ b/src/main/java/engineering/swat/watch/impl/jdk/JDKPoller.java @@ -34,9 +34,11 @@ import java.io.Closeable; import java.io.IOException; import java.nio.file.FileSystems; +import java.nio.file.Path; import java.nio.file.WatchEvent; import java.nio.file.WatchKey; import java.nio.file.WatchService; +import java.nio.file.Watchable; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -53,6 +55,7 @@ import com.sun.nio.file.ExtendedWatchEventModifier; +import engineering.swat.watch.impl.mac.MacWatchService; import engineering.swat.watch.impl.util.SubscriptionKey; /** @@ -73,7 +76,7 @@ private JDKPoller() {} static { try { - service = FileSystems.getDefault().newWatchService(); + service = Platform.get().newWatchService(); } catch (IOException e) { throw new RuntimeException("Could not start watcher", e); } @@ -121,12 +124,13 @@ public static Closeable register(SubscriptionKey path, Consumer { try { + Watchable watchable = Platform.get().newWatchable(path.getPath()); WatchEvent.Kind[] kinds = new WatchEvent.Kind[]{ ENTRY_CREATE, ENTRY_MODIFY, OVERFLOW, ENTRY_DELETE }; if (path.isRecursive()) { - return path.getPath().register(service, kinds, ExtendedWatchEventModifier.FILE_TREE); + return watchable.register(service, kinds, ExtendedWatchEventModifier.FILE_TREE); } else { - return path.getPath().register(service, kinds); + return watchable.register(service, kinds); } } catch (IOException e) { throw new RuntimeException(e); @@ -156,4 +160,56 @@ public void close() throws IOException { throw new IOException("The registration was canceled"); } } + + private static interface Platform { + WatchService newWatchService() throws IOException; + Watchable newWatchable(Path path); + + static final Platform MAC = new Platform() { + @Override + public WatchService newWatchService() throws IOException { + return new MacWatchService(); + } + @Override + public Watchable newWatchable(Path path) { + return MacWatchService.newWatchable(path); + } + }; + + static final Platform DEFAULT = new Platform() { + @Override + public WatchService newWatchService() throws IOException { + return FileSystems.getDefault().newWatchService(); + } + @Override + public Watchable newWatchable(Path path) { + return path; + } + }; + + static final Platform CURRENT = current(); // Assumption: the platform doesn't change + + private static Platform current() { + if (com.sun.jna.Platform.isMac()) { + var key = "engineering.swat.java-watch.mac"; + var val = System.getProperty(key); + if (val != null) { + if (val.equals("fsevents")) { + return MAC; + } else if (val.equals("jdk")) { + return DEFAULT; + } else { + logger.warn("Unexpected value \"{}\" for system property \"{}\". Using value \"jdk\" instead.", val, key); + return DEFAULT; + } + } + } + + return DEFAULT; + } + + static Platform get() { + return CURRENT; + } + } } diff --git a/src/main/java/engineering/swat/watch/impl/mac/MacWatchKey.java b/src/main/java/engineering/swat/watch/impl/mac/MacWatchKey.java new file mode 100644 index 00000000..5d20c056 --- /dev/null +++ b/src/main/java/engineering/swat/watch/impl/mac/MacWatchKey.java @@ -0,0 +1,265 @@ +/* + * BSD 2-Clause License + * + * Copyright (c) 2023, Swat.engineering + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package engineering.swat.watch.impl.mac; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.Watchable; +import java.nio.file.WatchEvent.Kind; +import java.nio.file.WatchEvent.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import org.checkerframework.checker.nullness.qual.Nullable; + +import com.sun.nio.file.ExtendedWatchEventModifier; + +class MacWatchKey implements WatchKey { + private final MacWatchable watchable; + private final MacWatchService service; + private final PendingEvents pendingEvents; + private final NativeEventStream stream; + + private volatile Configuration config = new Configuration(); + private volatile boolean cancelled = false; + + MacWatchKey(MacWatchable watchable, MacWatchService service) throws IOException { + this.watchable = watchable; + this.service = service; + this.pendingEvents = new PendingEvents(); + this.stream = new NativeEventStream(watchable.getPath(), new OfferWatchEvent()); + } + + /** + * Initializes this watch key by: (1) configuring it with the given + * {@code kinds} and {@code modifiers}; (2) opening a native event stream, + * if none is open yet for this watch key. This method can be invoked + * multiple times. If this watch key is invalid, then invoking this method + * has no effect. + * + * @return This watch key + */ + MacWatchKey initialize(Kind[] kinds, Modifier[] modifiers) throws IOException { + if (isValid()) { + config = new Configuration(kinds, modifiers); + stream.open(); + } + return this; + } + + /** + * Auxiliary container to manage the internal state of this watch key in a + * single place (to make it easier to reason about concurrent accesses). + */ + private class PendingEvents { + private final BlockingQueue> pendingEvents = new LinkedBlockingQueue<>(); + private volatile boolean signalled = false; + + // Following the documentation `WatchKey`, initially, this watch key is + // *ready* (i.e., `signalled` is false). When an event is offered, this + // watch key becomes *signalled* and is enqueued at `service`. + // Subsequently, this watch key remains signalled until it is reset; not + // until the pending events are polled. Thus, at the same time, + // `pendingEvents` can be empty and `signalled` can be true. The + // interplay between `pendingEvents` and `signalled` is quite tricky, + // and potentially subject to harmful races. The comments below the + // following methods argue why such harmful races won't happen. + + void offerAndSignal(WatchEvent event) { + pendingEvents.offer(event); + if (!signalled) { + signalled = true; + service.offer(MacWatchKey.this); + } + } + + List> drain() { + var list = new ArrayList>(pendingEvents.size()); + pendingEvents.drainTo(list); + return list; + } + + void resignalIfNonEmpty() { + if (signalled && !pendingEvents.isEmpty()) { + service.offer(MacWatchKey.this); + } else { + signalled = false; + } + } + + // The crucial property that needs to be maintained is that when + // `resignalIfNonEmpty` returns, either this watch key has been, or will + // be, enqueued at `service`, or `signalled` is false. Otherwise, until + // a next invocation of `reset` (including `resignalIfNonEmpty`), + // consumers of `service` won't be able to dequeue this watch key (it + // won't be queued by `offerAndSignal` while `signalled` is true), even + // when `pendingEvents` becomes non-empty---this causes consumers to + // miss events. Note: The documentation of `WatchService` doesn't + // specify the need for a next invocation of `reset` after a succesful + // one. + // + // To argue that the property holds, there are two cases to analyze: + // + // - If the then-branch of `resignalIfNonEmpty` is executed, then + // this watch key has been enqueued at `service`, so the property + // holds. Note: It doesn't matter if, by the time + // `resignalIfNonEmpty` returns, this watch key has already been + // dequeued by another thread. This is because that other thread is + // then responsible to make a next invocation of `reset` (including + // `resignalIfNonEmpty`) after its usage of this watch key. + // + // - If the else-branch of `resignalIfNonEmpty` is executed, then + // `signalled` may become `true` right after it's set to `false`. + // This happens when another thread concurrently invokes + // `offerAndSignal`. (There are no other places where `signalled` + // is modified.) But then, as part of `offerAndSignal`, this watch + // key will be enqueued at `service` by the other thread, too, so + // the property holds. Note: If we were to change the order of the + // statements in `offerAndSignal`, the property no longer holds. + } + + /** + * Handler for native events, issued by macOS. When invoked, it checks if + * the native event is eligible for downstream consumption, creates and + * enqueues a {@link WatchEvent}, and signals the service (when needed). + */ + private class OfferWatchEvent implements NativeEventHandler { + @Override + public void handle(Kind kind, T context) { + if (!cancelled && !config.ignore(kind, context)) { + + var event = new WatchEvent() { + @Override + public Kind kind() { + return kind; + } + @Override + public int count() { + // We currently don't need/use event counts, so let's + // keep the code simple for now. + throw new UnsupportedOperationException(); + } + @Override + public T context() { + return context; + } + }; + + pendingEvents.offerAndSignal(event); + } + } + } + + /** + * Configuration of a watch key that affects which events to + * {@link #ignore(Kind, Path)}. + */ + private static class Configuration { + private final Kind[] kinds; + private final boolean watchFileTree; + + public Configuration() { + this(new Kind[0], new Modifier[0]); + } + + public Configuration(Kind[] kinds, Modifier[] modifiers) { + // Extract only the relevant information from `modifiers` + var watchFileTree = false; + for (var m : modifiers) { + watchFileTree |= m == ExtendedWatchEventModifier.FILE_TREE; + } + + this.kinds = Arrays.copyOf(kinds, kinds.length); + this.watchFileTree = watchFileTree; + } + + /** + * Tests if an event should be ignored by a watch key with this + * configuration. This is the case when one of the following is true: + * (a) the watch key isn't configured to watch events of the given + * {@code kind}; (b) the watch key is configured to watch only a single + * directory, but the given {@code context} (a {@code Path}) points to a + * file in a subdirectory. + */ + public boolean ignore(Kind kind, @Nullable Object context) { + for (var k : kinds) { + if (k == kind) { + if (watchFileTree) { + return false; + } else { // Watch a single directory + return context instanceof Path && + ((Path) context).getNameCount() > 1; // File in subdirectory + } + } + } + return true; + } + } + + // -- WatchKey -- + + @Override + public boolean isValid() { + return !cancelled && !service.isClosed(); + } + + @Override + public List> pollEvents() { + return pendingEvents.drain(); + } + + @Override + public boolean reset() { + if (!isValid()) { + return false; + } + + pendingEvents.resignalIfNonEmpty(); + + // Invalidation of this key *during* the invocation of this method is + // observationally equivalent to invalidation immediately *after*. Thus, + // assume it doesn't happen and return `true`. + return true; + } + + @Override + public void cancel() { + cancelled = true; + watchable.unregister(service); + stream.close(); + } + + @Override + public Watchable watchable() { + return watchable; + } +} diff --git a/src/main/java/engineering/swat/watch/impl/mac/MacWatchService.java b/src/main/java/engineering/swat/watch/impl/mac/MacWatchService.java new file mode 100644 index 00000000..7a0de8e3 --- /dev/null +++ b/src/main/java/engineering/swat/watch/impl/mac/MacWatchService.java @@ -0,0 +1,88 @@ +/* + * BSD 2-Clause License + * + * Copyright (c) 2023, Swat.engineering + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package engineering.swat.watch.impl.mac; + +import java.io.IOException; +import java.nio.file.ClosedWatchServiceException; +import java.nio.file.Path; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import org.checkerframework.checker.nullness.qual.Nullable; + +public class MacWatchService implements WatchService { + final BlockingQueue pendingKeys = new LinkedBlockingQueue<>(); + volatile boolean closed = false; + + boolean offer(MacWatchKey key) { + return pendingKeys.offer(key); + } + + boolean isClosed() { + return closed; + } + + public static MacWatchable newWatchable(Path path) { + return new MacWatchable(path); + } + + // -- WatchService -- + + @Override + public void close() throws IOException { + closed = true; + // Note: We currently don't support blocking poll/take, so no additional + // logic is needed here to interrupt waiting threads (as specified in + // the documentation of `close`). + } + + @Override + public @Nullable WatchKey poll() { + if (closed) { + throw new ClosedWatchServiceException(); + } else { + return pendingKeys.poll(); + } + } + + @Override + public @Nullable WatchKey poll(long timeout, TimeUnit unit) throws InterruptedException { + // We currently don't need/use blocking operations, so let's keep the + // code simple for now. + throw new UnsupportedOperationException(); + } + + @Override + public WatchKey take() throws InterruptedException { + // We currently don't need/use blocking operations, so let's keep the + // code simple for now. + throw new UnsupportedOperationException(); + } +} diff --git a/src/main/java/engineering/swat/watch/impl/mac/MacWatchable.java b/src/main/java/engineering/swat/watch/impl/mac/MacWatchable.java new file mode 100644 index 00000000..1a85eddb --- /dev/null +++ b/src/main/java/engineering/swat/watch/impl/mac/MacWatchable.java @@ -0,0 +1,101 @@ +/* + * BSD 2-Clause License + * + * Copyright (c) 2023, Swat.engineering + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package engineering.swat.watch.impl.mac; + +import static java.nio.file.StandardWatchEventKinds.OVERFLOW; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.nio.file.Watchable; +import java.nio.file.WatchEvent.Kind; +import java.nio.file.WatchEvent.Modifier; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Stream; + +class MacWatchable implements Watchable { + private final Path path; + private final Map registrations; + + MacWatchable(Path path) { + this.path = path; + this.registrations = new ConcurrentHashMap<>(); + } + + Path getPath() { + return path; + } + + void unregister(MacWatchService watcher) { + registrations.remove(watcher); + } + + // -- Watchable -- + + @Override + public WatchKey register(WatchService watcher, Kind[] events, Modifier... modifiers) throws IOException { + if (!(watcher instanceof MacWatchService)) { + throw new IllegalArgumentException("A `MacWatchable` must be registered with a `MacWatchService`"); + } + + // Add `OVERFLOW` to the array (demanded by this method's specification) + if (Stream.of(events).noneMatch(OVERFLOW::equals)) { + events = Stream + .concat(Stream.of(events), Stream.of(OVERFLOW)) + .toArray(Kind[]::new); + } + + // Wrap any `IOException` thrown by the constructor of `MacWatchKey` in + // an `UncheckedIOException`. Intended to be used when invoking + // `computeIfAbsent`. + Function newMacWatchKey = service -> { + try { + return new MacWatchKey(this, service); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }; + + try { + return registrations + .computeIfAbsent((MacWatchService) watcher, newMacWatchKey) + .initialize(events, modifiers); + } catch (UncheckedIOException e) { + throw e.getCause(); + } + } + + @Override + public WatchKey register(WatchService watcher, Kind... events) throws IOException { + return register(watcher, events, new WatchEvent.Modifier[0]); + } +} diff --git a/src/main/java/engineering/swat/watch/impl/mac/NativeEventHandler.java b/src/main/java/engineering/swat/watch/impl/mac/NativeEventHandler.java new file mode 100644 index 00000000..bf3e88f4 --- /dev/null +++ b/src/main/java/engineering/swat/watch/impl/mac/NativeEventHandler.java @@ -0,0 +1,55 @@ +/* + * BSD 2-Clause License + * + * Copyright (c) 2023, Swat.engineering + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package engineering.swat.watch.impl.mac; + +import java.nio.file.WatchEvent; +import java.nio.file.WatchEvent.Kind; + +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + *

+ * Handler for native events, intended to be used in a {@link NativeEventStream} + * callback to construct {@link WatchEvent}s (and propagate them for downstream + * consumption). + *

+ * + *

+ * In each invocation, the types of {@code kind} and {@code context} depend + * specifically on the given native event: they're {@code Kind} and + * {@code Path} for non-overflows, but they're {@code Kind} and + * {@code Object} for overflows. This precision is needed to construct + * {@link WatchEvent}s, where the types of {@code kind} and {@code context} need + * to be correlated. Note: {@link java.util.function.BiConsumer} doesn't give + * the required precision (i.e., its type parameters are initialized only once + * for all invocations). + *

+ */ +@FunctionalInterface +interface NativeEventHandler { + void handle(Kind kind, @Nullable T context); +} diff --git a/src/main/java/engineering/swat/watch/impl/mac/NativeEventStream.java b/src/main/java/engineering/swat/watch/impl/mac/NativeEventStream.java new file mode 100644 index 00000000..58856731 --- /dev/null +++ b/src/main/java/engineering/swat/watch/impl/mac/NativeEventStream.java @@ -0,0 +1,300 @@ +/* + * BSD 2-Clause License + * + * Copyright (c) 2023, Swat.engineering + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package engineering.swat.watch.impl.mac; + +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCreateFlag.FILE_EVENTS; +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCreateFlag.NO_DEFER; +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCreateFlag.WATCH_ROOT; +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag.ITEM_CREATED; +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag.ITEM_INODE_META_MOD; +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag.ITEM_MODIFIED; +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag.ITEM_REMOVED; +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag.ITEM_RENAMED; +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag.MUST_SCAN_SUB_DIRS; +import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE; +import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE; +import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; +import static java.nio.file.StandardWatchEventKinds.OVERFLOW; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import org.checkerframework.checker.nullness.qual.Nullable; + +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.platform.mac.CoreFoundation; +import com.sun.jna.platform.mac.CoreFoundation.CFArrayRef; +import com.sun.jna.platform.mac.CoreFoundation.CFIndex; +import com.sun.jna.platform.mac.CoreFoundation.CFStringRef; + +import engineering.swat.watch.impl.mac.apis.DispatchObjects; +import engineering.swat.watch.impl.mac.apis.DispatchQueue; +import engineering.swat.watch.impl.mac.apis.FileSystemEvents; +import engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCallback; + +// Note: This file is designed to be the only place in this package where JNA is +// used and/or the native APIs are invoked. If the need to do so arises outside +// this file, consider extending this file to offer the required services +// without exposing JNA and/or the native APIs. + +/** + *

+ * Stream of native events for a path, issued by macOS. It's a facade-like + * object that hides the low-level native APIs behind a higher-level interface. + *

+ * + *

+ * Note: Methods {@link #open()} and {@link #close()} synchronize on this object + * to avoid races. The synchronization overhead is expected to be negligible, as + * these methods are expected to be rarely invoked. + *

+ */ +class NativeEventStream implements Closeable { + + // Native APIs + private static final CoreFoundation CF = CoreFoundation.INSTANCE; + private static final DispatchObjects DO = DispatchObjects.INSTANCE; + private static final DispatchQueue DQ = DispatchQueue.INSTANCE; + private static final FileSystemEvents FSE = FileSystemEvents.INSTANCE; + + // Native memory + private @Nullable FSEventStreamCallback callback; // Keep reference to avoid premature GC'ing + private @Nullable Pointer stream; + private @Nullable Pointer queue; + // Note: These fields aren't volatile, as all reads/write from/to them are + // inside synchronized blocks. Be careful to not break this invariant. + + private final Path path; + private final NativeEventHandler handler; + private volatile boolean closed; + + public NativeEventStream(Path path, NativeEventHandler handler) throws IOException { + this.path = path.toRealPath(); // Resolve symbolic links + this.handler = handler; + this.closed = true; + } + + public synchronized void open() { + if (!closed) { + return; + } else { + closed = false; + } + + // Allocate native memory + callback = createCallback(); + stream = createFSEventStream(callback); + queue = createDispatchQueue(); + + // Start the stream + var streamNonNull = stream; + if (streamNonNull != null) { + FSE.FSEventStreamSetDispatchQueue(streamNonNull, queue); + FSE.FSEventStreamStart(streamNonNull); + } + } + + private FSEventStreamCallback createCallback() { + return new FSEventStreamCallback() { + @Override + public void callback(Pointer streamRef, Pointer clientCallBackInfo, + long numEvents, Pointer eventPaths, Pointer eventFlags, Pointer eventIds) { + // This function is called each time native events are issued by + // macOS. The purpose of this function is to perform the minimal + // amount of processing to hide the native APIs from downstream + // consumers, who are offered native events via `handler`. + + var paths = eventPaths.getStringArray(0, (int) numEvents); + var flags = eventFlags.getIntArray(0, (int) numEvents); + + for (var i = 0; i < numEvents; i++) { + var context = path.relativize(Path.of(paths[i])); + + // Note: Multiple "physical" native events might be + // coalesced into a single "logical" native event, so the + // following series of checks should be if-statements + // (instead of if/else-statements). + if (any(flags[i], ITEM_CREATED.mask)) { + handler.handle(ENTRY_CREATE, context); + } + if (any(flags[i], ITEM_REMOVED.mask)) { + handler.handle(ENTRY_DELETE, context); + } + if (any(flags[i], ITEM_MODIFIED.mask | ITEM_INODE_META_MOD.mask)) { + handler.handle(ENTRY_MODIFY, context); + } + if (any(flags[i], MUST_SCAN_SUB_DIRS.mask)) { + handler.handle(OVERFLOW, null); + } + if (any(flags[i], ITEM_RENAMED.mask)) { + // For now, check if the file exists to determine if the + // event pertains to the target of the rename (if it + // exists) or to the source (else). This is an + // approximation. It might be more accurate to maintain + // an internal index (but getting the concurrency right + // requires care). + if (Files.exists(Path.of(paths[i]))) { + handler.handle(ENTRY_CREATE, context); + } else { + handler.handle(ENTRY_DELETE, context); + } + } + } + } + + private boolean any(int bits, int mask) { + return (bits & mask) != 0; + } + }; + } + + private Pointer createFSEventStream(FSEventStreamCallback callback) { + try (var pathsToWatch = new Strings(path.toString())) { + var allocator = CF.CFAllocatorGetDefault(); + var context = Pointer.NULL; + var sinceWhen = FSE.FSEventsGetCurrentEventId(); + var latency = 0.15; + var flags = NO_DEFER.mask | WATCH_ROOT.mask | FILE_EVENTS.mask; + return FSE.FSEventStreamCreate(allocator, callback, context, pathsToWatch.toCFArray(), sinceWhen, latency, flags); + } + } + + private Pointer createDispatchQueue() { + var label = "engineering.swat.watch"; + var attr = Pointer.NULL; + return DQ.dispatch_queue_create(label, attr); + } + + // -- Closeable -- + + @Override + public synchronized void close() { + if (closed) { + return; + } else { + closed = true; + } + + var streamNonNull = stream; + var queueNonNull = queue; + if (streamNonNull != null && queueNonNull != null) { + + // Stop the stream + FSE.FSEventStreamStop(streamNonNull); + FSE.FSEventStreamSetDispatchQueue(streamNonNull, Pointer.NULL); + FSE.FSEventStreamInvalidate(streamNonNull); + + // Deallocate native memory + DO.dispatch_release(queueNonNull); + FSE.FSEventStreamRelease(streamNonNull); + queue = null; + stream = null; + callback = null; + } + } +} + +/** + * Array of strings in native memory, needed to create a new native event stream + * (i.e., the {@code pathsToWatch} argument of {@code FSEventStreamCreate} is an + * array of strings). + */ +class Strings implements AutoCloseable { + + // Native APIs + private static final CoreFoundation CF = CoreFoundation.INSTANCE; + + // Native memory + private final CFStringRef[] strings; + private final CFArrayRef array; + + private volatile boolean closed = false; + + public Strings(String... strings) { + // Allocate native memory + this.strings = createCFStrings(strings); + this.array = createCFArray(this.strings); + } + + public CFArrayRef toCFArray() { + if (closed) { + throw new IllegalStateException("Strings are already deallocated"); + } else { + return array; + } + } + + private static CFStringRef[] createCFStrings(String[] pathsToWatch) { + return Arrays.stream(pathsToWatch) + .map(CFStringRef::createCFString) + .toArray(CFStringRef[]::new); + } + + private static CFArrayRef createCFArray(CFStringRef[] strings) { + var n = strings.length; + var size = Native.getNativeSize(CFStringRef.class); + + // Create a temporary array of pointers to the strings (automatically + // freed when `values` goes out of scope) + var values = new Memory(n * size); + for (int i = 0; i < n; i++) { + values.setPointer(i * size, strings[i].getPointer()); + } + + // Create a permanent array based on the temporary array + var alloc = CF.CFAllocatorGetDefault(); + var numValues = new CFIndex(n); + var callBacks = Pointer.NULL; + return CF.CFArrayCreate(alloc, values, numValues, callBacks); + } + + // -- AutoCloseable -- + + @Override + public void close() { + if (closed) { + throw new IllegalStateException("Strings are already deallocated"); + } else { + closed = true; + } + + // Deallocate native memory + for (var s : strings) { + if (s != null) { + s.release(); + } + } + if (array != null) { + array.release(); + } + } +} diff --git a/src/main/java/engineering/swat/watch/impl/mac/apis/DispatchObjects.java b/src/main/java/engineering/swat/watch/impl/mac/apis/DispatchObjects.java new file mode 100644 index 00000000..41bb5ee6 --- /dev/null +++ b/src/main/java/engineering/swat/watch/impl/mac/apis/DispatchObjects.java @@ -0,0 +1,47 @@ +/* + * BSD 2-Clause License + * + * Copyright (c) 2023, Swat.engineering + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package engineering.swat.watch.impl.mac.apis; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; + +/** + * Interface for the "Dispatch Objects" API collection of the "Dispatch" + * framework. + * + * @see https://developer.apple.com/documentation/dispatch/dispatch_objects?language=objc + */ +public interface DispatchObjects extends Library { + DispatchObjects INSTANCE = Native.load("c", DispatchObjects.class); + + /** + * @param object {@code dispatch_object_t} + * @see https://developer.apple.com/documentation/dispatch/1496328-dispatch_release?language=objc + */ + void dispatch_release(Pointer object); +} diff --git a/src/main/java/engineering/swat/watch/impl/mac/apis/DispatchQueue.java b/src/main/java/engineering/swat/watch/impl/mac/apis/DispatchQueue.java new file mode 100644 index 00000000..747d87a5 --- /dev/null +++ b/src/main/java/engineering/swat/watch/impl/mac/apis/DispatchQueue.java @@ -0,0 +1,49 @@ +/* + * BSD 2-Clause License + * + * Copyright (c) 2023, Swat.engineering + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package engineering.swat.watch.impl.mac.apis; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; + +/** + * Interface for the "Dispatch Queue" API collection of the "Dispatch" + * framework. + * + * @see https://developer.apple.com/documentation/dispatch/dispatch_queue?language=objc + */ +public interface DispatchQueue extends Library { + DispatchQueue INSTANCE = Native.load("c", DispatchQueue.class); + + /** + * @param label {@code dispatch_queue_t} + * @param attr {@code const char*} + * @return {@code dispatch_queue_t} + * @see https://developer.apple.com/documentation/dispatch/1453030-dispatch_queue_create?language=objc + */ + Pointer dispatch_queue_create(String label, Pointer attr); +} diff --git a/src/main/java/engineering/swat/watch/impl/mac/apis/FileSystemEvents.java b/src/main/java/engineering/swat/watch/impl/mac/apis/FileSystemEvents.java new file mode 100644 index 00000000..d81a0447 --- /dev/null +++ b/src/main/java/engineering/swat/watch/impl/mac/apis/FileSystemEvents.java @@ -0,0 +1,193 @@ +/* + * BSD 2-Clause License + * + * Copyright (c) 2023, Swat.engineering + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package engineering.swat.watch.impl.mac.apis; + +import com.sun.jna.Callback; +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.platform.mac.CoreFoundation.CFAllocatorRef; +import com.sun.jna.platform.mac.CoreFoundation.CFArrayRef; +import com.sun.jna.platform.mac.CoreFoundation.CFStringRef; + +/** + * Interface for the "File System Events" API collection of the "Core Services" + * framework. + * + * https://developer.apple.com/documentation/coreservices/file_system_events?language=objc + */ +public interface FileSystemEvents extends Library { + FileSystemEvents INSTANCE = Native.load("CoreServices", FileSystemEvents.class); + + // -- Functions -- + + /** + * @param allocator {@code CFAllocator} + * @param callback {@code FSEventStreamCallback} + * @param context {@code FSEventStreamContext} + * @param pathsToWatch {@code CFArray} + * @param sinceWhen {@code FSEventStreamEventId} + * @param latency {@code CFTimeInterval} + * @param flags {@code FSEventStreamCreateFlags} + * @return {@code FSEventStreamRef} + * @see https://developer.apple.com/documentation/coreservices/1443980-fseventstreamcreate?language=objc + */ + Pointer FSEventStreamCreate(CFAllocatorRef allocator, FSEventStreamCallback callback, + Pointer context, CFArrayRef pathsToWatch, long sinceWhen, double latency, int flags); + + /** + * @param streamRef {@code FSEventStreamRef} + * @see https://developer.apple.com/documentation/coreservices/1446990-fseventstreaminvalidate?language=objc + */ + void FSEventStreamInvalidate(Pointer streamRef); + + /** + * @param streamRef {@code FSEventStreamRef} + * @see https://developer.apple.com/documentation/coreservices/1445989-fseventstreamrelease?language=objc + */ + void FSEventStreamRelease(Pointer streamRef); + + /** + * @param streamRef {@code FSEventStreamRef} + * @param q {@code dispatch_queue_t} + * @see https://developer.apple.com/documentation/coreservices/1444164-fseventstreamsetdispatchqueue?language=objc + */ + void FSEventStreamSetDispatchQueue(Pointer streamRef, Pointer q); + + /** + * @param streamRef {@code FSEventStreamRef} + * @see https://developer.apple.com/documentation/coreservices/1444302-fseventstreamshow?language=objc + */ + boolean FSEventStreamShow(Pointer streamRef); + + /** + * @param streamRef {@code FSEventStreamRef} + * @return {@code Boolean} + * @see https://developer.apple.com/documentation/coreservices/1448000-fseventstreamstart?language=objc + */ + boolean FSEventStreamStart(Pointer streamRef); + + /** + * @param streamRef {@code FSEventStreamRef} + * @see https://developer.apple.com/documentation/coreservices/1447673-fseventstreamstop?language=objc + */ + void FSEventStreamStop(Pointer streamRef); + + /** + * @return {@code FSEventStreamEventId} + * @see https://developer.apple.com/documentation/coreservices/1442917-fseventsgetcurrenteventid?language=objc + */ + long FSEventsGetCurrentEventId(); + + // -- Enumerations -- + + /** + * @see https://developer.apple.com/documentation/coreservices/1455376-fseventstreamcreateflags?language=objc + */ + static enum FSEventStreamCreateFlag { + NONE (0x00000000), + USE_CF_TYPES (0x00000001), + NO_DEFER (0x00000002), + WATCH_ROOT (0x00000004), + IGNORE_SELF (0x00000008), + FILE_EVENTS (0x00000010), + MARK_SELF (0x00000020), + FULL_HISTORY (0x00000080), + USE_EXTENDED_DATA(0x00000040), + WITH_DOC_ID (0x00000100); + + public final int mask; + + private FSEventStreamCreateFlag(int mask) { + this.mask = mask; + } + } + + /** + * @see https://developer.apple.com/documentation/coreservices/1455361-fseventstreameventflags?language=objc + */ + static enum FSEventStreamEventFlag { + NONE (0x00000000), + MUST_SCAN_SUB_DIRS (0x00000001), + USER_DROPPED (0x00000002), + KERNEL_DROPPED (0x00000004), + EVENT_IDS_WRAPPED (0x00000008), + HISTORY_DONE (0x00000010), + ROOT_CHANGED (0x00000020), + MOUNT (0x00000040), + UNMOUNT (0x00000080), + ITEM_CHANGE_OWNER (0x00004000), + ITEM_CREATED (0x00000100), + ITEM_FINDER_INFO_MOD (0x00002000), + ITEM_INODE_META_MOD (0x00000400), + ITEM_IS_DIR (0x00020000), + ITEM_IS_FILE (0x00010000), + ITEM_IS_HARD_LINK (0x00100000), + ITEM_IS_LAST_HARD_LINK(0x00200000), + ITEM_IS_SYMLINK (0x00040000), + ITEM_MODIFIED (0x00001000), + ITEM_REMOVED (0x00000200), + ITEM_RENAMED (0x00000800), + ITEM_XATTR_MOD (0x00008000), + OWN_EVENT (0x00080000), + ITEM_CLONED (0x00400000); + + public final int mask; + + private FSEventStreamEventFlag(int mask) { + this.mask = mask; + } + } + + // -- Data types -- + + static interface FSEventStreamCallback extends Callback { + /** + * @param streamRef {@code ConstFSEventStreamRef} + * @param clientCallBackInfo {@code void*} + * @param numEvents {@code size_t} + * @param eventPaths {@code void*} + * @param eventFlags {@code FSEventStreamEventFlags*} + * @param eventIds {@code FSEventStreamEventIds*} + * @see https://developer.apple.com/documentation/coreservices/fseventstreamcallback?language=objc + */ + void callback(Pointer streamRef, Pointer clientCallBackInfo, + long numEvents, Pointer eventPaths, Pointer eventFlags, Pointer eventIds); + } + + // -- Constants -- + + /** + * @see https://developer.apple.com/documentation/coreservices/kfseventstreameventextendeddatapathkey?language=objc + */ + static final CFStringRef kFSEventStreamEventExtendedDataPathKey = CFStringRef.createCFString("path"); + + /** + * @see https://developer.apple.com/documentation/coreservices/kfseventstreameventextendedfileidkey?language=objc + */ + static final CFStringRef kFSEventStreamEventExtendedFileIDKey = CFStringRef.createCFString("fileID"); +} diff --git a/src/test/java/engineering/swat/watch/SingleFileTests.java b/src/test/java/engineering/swat/watch/SingleFileTests.java index 7fe04a2b..26c0f015 100644 --- a/src/test/java/engineering/swat/watch/SingleFileTests.java +++ b/src/test/java/engineering/swat/watch/SingleFileTests.java @@ -130,7 +130,7 @@ void noRescanOnOverflow() throws IOException, InterruptedException { try (var watch = startWatchAndTriggerOverflow(Approximation.NONE, bookkeeper)) { Thread.sleep(TestHelper.SHORT_WAIT.toMillis()); - await("Overflow shouldn't trigger created, modified, or deleted events") + await("Overflow shouldn't trigger created, modified, or deleted events: " + bookkeeper) .until(() -> bookkeeper.events().kind(CREATED, MODIFIED, DELETED).none()); await("Overflow should be visible to user-defined event handler") .until(() -> bookkeeper.events().kind(OVERFLOW).any()); diff --git a/src/test/java/engineering/swat/watch/SmokeTests.java b/src/test/java/engineering/swat/watch/SmokeTests.java index 65dcc719..44cdd119 100644 --- a/src/test/java/engineering/swat/watch/SmokeTests.java +++ b/src/test/java/engineering/swat/watch/SmokeTests.java @@ -114,11 +114,11 @@ void watchSingleFile() throws IOException { } @Test - void moveRegularFileBetweenNestedDirectories() throws IOException { + void moveRegularFile() throws IOException { var parent = testDir.getTestDirectory(); var child1 = Files.createDirectories(parent.resolve("from")); var child2 = Files.createDirectories(parent.resolve("to")); - var file = Files.createFile(child1.resolve("file.txt")); + var regularFile = Files.createFile(child1.resolve("file.txt")); var parentWatchBookkeeper = new TestHelper.Bookkeeper(); var parentWatchConfig = Watch @@ -137,7 +137,7 @@ void moveRegularFileBetweenNestedDirectories() throws IOException { var fileWatchBookkeeper = new TestHelper.Bookkeeper(); var fileWatchConfig = Watch - .build(file, WatchScope.PATH_ONLY) + .build(regularFile, WatchScope.PATH_ONLY) .on(fileWatchBookkeeper); try (var parentWatch = parentWatchConfig.start(); @@ -145,17 +145,17 @@ void moveRegularFileBetweenNestedDirectories() throws IOException { var child2Watch = child2WatchConfig.start(); var fileWatch = fileWatchConfig.start()) { - var source = child1.resolve(file.getFileName()); - var target = child2.resolve(file.getFileName()); + var source = child1.resolve(regularFile.getFileName()); + var target = child2.resolve(regularFile.getFileName()); Files.move(source, target); - await("Move should be observed as delete by `parent` watch (file tree)") - .until(() -> parentWatchBookkeeper - .events().kind(DELETED).rootPath(parent).relativePath(parent.relativize(source)).any()); - - await("Move should be observed as create by `parent` watch (file tree)") - .until(() -> parentWatchBookkeeper - .events().kind(CREATED).rootPath(parent).relativePath(parent.relativize(target)).any()); + for (var e : new WatchEvent[] { + new WatchEvent(DELETED, parent, parent.relativize(source)), + new WatchEvent(CREATED, parent, parent.relativize(target)) + }) { + await("Move should be observed as delete/create by `parent` watch (file tree): " + e) + .until(() -> parentWatchBookkeeper.events().any(e)); + } await("Move should be observed as delete by `child1` watch (single directory)") .until(() -> child1WatchBookkeeper @@ -170,4 +170,82 @@ void moveRegularFileBetweenNestedDirectories() throws IOException { .events().kind(DELETED).rootPath(source).any()); } } + + @Test + void moveDirectory() throws IOException { + var parent = testDir.getTestDirectory(); + var child1 = Files.createDirectories(parent.resolve("from")); + var child2 = Files.createDirectories(parent.resolve("to")); + + var directory = Files.createDirectory(child1.resolve("directory")); + var regularFile1 = Files.createFile(directory.resolve("file1.txt")); + var regularFile2 = Files.createFile(directory.resolve("file2.txt")); + + var parentWatchBookkeeper = new TestHelper.Bookkeeper(); + var parentWatchConfig = Watch + .build(parent, WatchScope.PATH_AND_ALL_DESCENDANTS) + .on(parentWatchBookkeeper); + + var child1WatchBookkeeper = new TestHelper.Bookkeeper(); + var child1WatchConfig = Watch + .build(child1, WatchScope.PATH_AND_CHILDREN) + .on(child1WatchBookkeeper); + + var child2WatchBookkeeper = new TestHelper.Bookkeeper(); + var child2WatchConfig = Watch + .build(child2, WatchScope.PATH_AND_CHILDREN) + .on(child2WatchBookkeeper); + + var directoryWatchBookkeeper = new TestHelper.Bookkeeper(); + var directoryWatchConfig = Watch + .build(directory, WatchScope.PATH_ONLY) + .on(directoryWatchBookkeeper); + + try (var parentWatch = parentWatchConfig.start(); + var child1Watch = child1WatchConfig.start(); + var child2Watch = child2WatchConfig.start(); + var fileWatch = directoryWatchConfig.start()) { + + var sourceDirectory = child1.resolve(directory.getFileName()); + var sourceRegularFile1 = sourceDirectory.resolve(regularFile1.getFileName()); + var sourceRegularFile2 = sourceDirectory.resolve(regularFile2.getFileName()); + + var targetDirectory = child2.resolve(directory.getFileName()); + var targetRegularFile1 = targetDirectory.resolve(regularFile1.getFileName()); + var targetRegularFile2 = targetDirectory.resolve(regularFile2.getFileName()); + + Files.move(sourceDirectory, targetDirectory); + + for (var e : new WatchEvent[] { + new WatchEvent(DELETED, parent, parent.relativize(sourceDirectory)), + new WatchEvent(CREATED, parent, parent.relativize(targetDirectory)), + // The following events currently *aren't* observed by the + // `parent` watch for the whole file tree: moving a directory + // doesn't trigger events for the deletion/creation of the files + // contained in it (neither using the general default/JDK + // implementation of Watch Service, nor using our special macOS + // implementation). + // + // new WatchEvent(DELETED, parent, parent.relativize(sourceRegularFile1)), + // new WatchEvent(DELETED, parent, parent.relativize(sourceRegularFile2)), + // new WatchEvent(CREATED, parent, parent.relativize(targetRegularFile1)), + // new WatchEvent(CREATED, parent, parent.relativize(targetRegularFile2)) + }) { + await("Move should be observed as delete/create by `parent` watch (file tree): " + e) + .until(() -> parentWatchBookkeeper.events().any(e)); + } + + await("Move should be observed as delete by `child1` watch (single directory)") + .until(() -> child1WatchBookkeeper + .events().kind(DELETED).rootPath(child1).relativePath(child1.relativize(sourceDirectory)).any()); + + await("Move should be observed as create by `child2` watch (single directory)") + .until(() -> child2WatchBookkeeper + .events().kind(CREATED).rootPath(child2).relativePath(child2.relativize(targetDirectory)).any()); + + await("Move should be observed as delete by `directory` watch") + .until(() -> directoryWatchBookkeeper + .events().kind(DELETED).rootPath(sourceDirectory).any()); + } + } } diff --git a/src/test/java/engineering/swat/watch/impl/mac/APIs.java b/src/test/java/engineering/swat/watch/impl/mac/APIs.java new file mode 100644 index 00000000..4b408b1a --- /dev/null +++ b/src/test/java/engineering/swat/watch/impl/mac/APIs.java @@ -0,0 +1,228 @@ +/* + * BSD 2-Clause License + * + * Copyright (c) 2023, Swat.engineering + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package engineering.swat.watch.impl.mac; + +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCreateFlag.FILE_EVENTS; +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCreateFlag.NO_DEFER; +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCreateFlag.USE_CF_TYPES; +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCreateFlag.USE_EXTENDED_DATA; +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCreateFlag.WATCH_ROOT; +import static org.awaitility.Awaitility.await; + +import java.io.BufferedReader; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.platform.mac.CoreFoundation; +import com.sun.jna.platform.mac.CoreFoundation.CFArrayRef; +import com.sun.jna.platform.mac.CoreFoundation.CFDictionaryRef; +import com.sun.jna.platform.mac.CoreFoundation.CFIndex; +import com.sun.jna.platform.mac.CoreFoundation.CFNumberRef; +import com.sun.jna.platform.mac.CoreFoundation.CFStringRef; + +import engineering.swat.watch.TestDirectory; +import engineering.swat.watch.impl.mac.apis.DispatchObjects; +import engineering.swat.watch.impl.mac.apis.DispatchQueue; +import engineering.swat.watch.impl.mac.apis.FileSystemEvents; +import engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag; + +@EnabledOnOs({OS.MAC}) +class APIs { + private static final Logger LOGGER = LogManager.getLogger(); + + // Native APIs + private static final CoreFoundation CF = CoreFoundation.INSTANCE; + private static final DispatchObjects DO = DispatchObjects.INSTANCE; + private static final DispatchQueue DQ = DispatchQueue.INSTANCE; + private static final FileSystemEvents FSE = FileSystemEvents.INSTANCE; + + @Test + void smokeTest() throws IOException { + try (var test = new TestDirectory()) { + var ready = new AtomicBoolean(false); + var paths = ConcurrentHashMap. newKeySet(); + + var s = test.getTestDirectory().toString(); + var handler = (MinimalWorkingExample.EventHandler) (path, inode, flags, id) -> { + synchronized (ready) { + while (!ready.get()) { + try { + ready.wait(); + } catch (InterruptedException e) { + LOGGER.error("Unexpected interrupt. Test likely to fail. Event ignored ({}).", prettyPrint(path, inode, flags, id)); + Thread.currentThread().interrupt(); + return; + } + } + } + paths.remove(path); + }; + + try (var mwe = new MinimalWorkingExample(s, handler, true)) { + var dir = test.getTestDirectory().toRealPath(); + paths.add(Files.writeString(dir.resolve("a.txt"), "foo").toString()); + paths.add(Files.writeString(dir.resolve("b.txt"), "bar").toString()); + paths.add(Files.createFile(dir.resolve("d.txt")).toString()); + + synchronized (ready) { + ready.set(true); + ready.notifyAll(); + } + + await("The event handler has been called").until(paths::isEmpty); + } + } + } + + public static void main(String[] args) throws IOException { + var s = args[0]; + var handler = (MinimalWorkingExample.EventHandler) (path, inode, flags, id) -> { + LOGGER.info(prettyPrint(path, inode, flags, id)); + }; + var useExtendedData = args.length >= 2 && Boolean.parseBoolean(args[1]); + + try (var mwe = new MinimalWorkingExample(s, handler, useExtendedData)) { + // Block the program from terminating until `ENTER` is pressed + new BufferedReader(new InputStreamReader(System.in)).readLine(); + } + } + + private static String prettyPrint(String path, long inode, int flags, long id) { + var flagsPrettyPrinted = Stream + .of(FSEventStreamEventFlag.values()) + .filter(f -> (f.mask & flags) == f.mask) + .map(Object::toString) + .collect(Collectors.joining(", ")); + + var format = "path: \"%s\", inode: %s, flags: [%s], id: %s"; + return String.format(format, path, inode, flagsPrettyPrinted, id); + } + + private static class MinimalWorkingExample implements Closeable { + private FileSystemEvents.FSEventStreamCallback callback; + private Pointer stream; + private Pointer queue; + + public MinimalWorkingExample(String s, EventHandler handler, boolean useExtendedData) { + + // Allocate singleton array of paths + CFStringRef pathToWatch = CFStringRef.createCFString(s); + CFArrayRef pathsToWatch = null; + { + var values = new Memory(Native.getNativeSize(CFStringRef.class)); + values.setPointer(0, pathToWatch.getPointer()); + pathsToWatch = CF.CFArrayCreate( + CF.CFAllocatorGetDefault(), + values, + new CFIndex(1), + null); + } // Automatically free `values` when it goes out of scope + + // Allocate callback + this.callback = (x1, x2, x3, x4, x5, x6) -> { + var paths = x4.getStringArray(0, (int) x3); + var inodes = new long[(int) x3]; + var flags = x5.getIntArray(0, (int) x3); + var ids = x6.getLongArray(0, (int) x3); + + if (useExtendedData) { + var extendedData = new CFArrayRef(x4); + for (int i = 0; i < x3; i++) { + var dictionary = new CFDictionaryRef(extendedData.getValueAtIndex(i)); + var dictionaryPath = dictionary.getValue(FileSystemEvents.kFSEventStreamEventExtendedDataPathKey); + var dictionaryInode = dictionary.getValue(FileSystemEvents.kFSEventStreamEventExtendedFileIDKey); + paths[i] = dictionaryPath == null ? null : new CFStringRef(dictionaryPath).stringValue(); + inodes[i] = dictionaryInode == null ? 0 : new CFNumberRef(dictionaryInode).longValue(); + } + } + + for (int i = 0; i < x3; i++) { + handler.handle(paths[i], inodes[i], flags[i], ids[i]); + } + }; + + // Allocate stream + this.stream = FSE.FSEventStreamCreate( + CF.CFAllocatorGetDefault(), + callback, + Pointer.NULL, + pathsToWatch, + FSE.FSEventsGetCurrentEventId(), + 0.15, + NO_DEFER.mask | WATCH_ROOT.mask | FILE_EVENTS.mask | + (useExtendedData ? USE_EXTENDED_DATA.mask | USE_CF_TYPES.mask : 0)); + + // Deallocate array of paths + pathsToWatch.release(); + pathToWatch.release(); + + // Allocate queue + this.queue = DQ.dispatch_queue_create("q", null); + + // Start stream + FSE.FSEventStreamSetDispatchQueue(stream, queue); + FSE.FSEventStreamStart(stream); + FSE.FSEventStreamShow(stream); + } + + @Override + public void close() throws IOException { + + // Stop stream + FSE.FSEventStreamStop(stream); + FSE.FSEventStreamSetDispatchQueue(stream, Pointer.NULL); + FSE.FSEventStreamInvalidate(stream); + + // Deallocate queue, stream, and callback + DO.dispatch_release(queue); + FSE.FSEventStreamRelease(stream); + queue = null; + stream = null; + callback = null; + } + + @FunctionalInterface + private static interface EventHandler { + void handle(String path, long inode, int flags, long id); + } + } +}