+ * 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
+ * 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.