Skip to content

Commit 3d244ad

Browse files
authored
Merge pull request #44 from SWAT-engineering/improved-macos-support/file-moves
Improved macOS support: Support for moving/renaming files
2 parents 4e3f180 + 4aba4b8 commit 3d244ad

File tree

4 files changed

+141
-31
lines changed

4 files changed

+141
-31
lines changed

src/main/java/engineering/swat/watch/impl/mac/NativeEventStream.java

+15
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag.ITEM_INODE_META_MOD;
3434
import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag.ITEM_MODIFIED;
3535
import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag.ITEM_REMOVED;
36+
import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag.ITEM_RENAMED;
3637
import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag.MUST_SCAN_SUB_DIRS;
3738
import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
3839
import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE;
@@ -41,6 +42,7 @@
4142

4243
import java.io.Closeable;
4344
import java.io.IOException;
45+
import java.nio.file.Files;
4446
import java.nio.file.Path;
4547
import java.util.Arrays;
4648

@@ -153,6 +155,19 @@ public void callback(Pointer streamRef, Pointer clientCallBackInfo,
153155
if (any(flags[i], MUST_SCAN_SUB_DIRS.mask)) {
154156
handler.handle(OVERFLOW, null);
155157
}
158+
if (any(flags[i], ITEM_RENAMED.mask)) {
159+
// For now, check if the file exists to determine if the
160+
// event pertains to the target of the rename (if it
161+
// exists) or to the source (else). This is an
162+
// approximation. It might be more accurate to maintain
163+
// an internal index (but getting the concurrency right
164+
// requires care).
165+
if (Files.exists(Path.of(paths[i]))) {
166+
handler.handle(ENTRY_CREATE, context);
167+
} else {
168+
handler.handle(ENTRY_DELETE, context);
169+
}
170+
}
156171
}
157172
}
158173

src/test/java/engineering/swat/watch/SingleFileTests.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ void noRescanOnOverflow() throws IOException, InterruptedException {
130130
try (var watch = startWatchAndTriggerOverflow(Approximation.NONE, bookkeeper)) {
131131
Thread.sleep(TestHelper.SHORT_WAIT.toMillis());
132132

133-
await("Overflow shouldn't trigger created, modified, or deleted events")
133+
await("Overflow shouldn't trigger created, modified, or deleted events: " + bookkeeper)
134134
.until(() -> bookkeeper.events().kind(CREATED, MODIFIED, DELETED).none());
135135
await("Overflow should be visible to user-defined event handler")
136136
.until(() -> bookkeeper.events().kind(OVERFLOW).any());

src/test/java/engineering/swat/watch/SmokeTests.java

+90-14
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
import org.junit.jupiter.api.AfterEach;
3939
import org.junit.jupiter.api.BeforeAll;
4040
import org.junit.jupiter.api.BeforeEach;
41-
import org.junit.jupiter.api.Disabled;
4241
import org.junit.jupiter.api.Test;
4342

4443

@@ -115,12 +114,11 @@ void watchSingleFile() throws IOException {
115114
}
116115

117116
@Test
118-
@Disabled
119-
void moveRegularFileBetweenNestedDirectories() throws IOException {
117+
void moveRegularFile() throws IOException {
120118
var parent = testDir.getTestDirectory();
121119
var child1 = Files.createDirectories(parent.resolve("from"));
122120
var child2 = Files.createDirectories(parent.resolve("to"));
123-
var file = Files.createFile(child1.resolve("file.txt"));
121+
var regularFile = Files.createFile(child1.resolve("file.txt"));
124122

125123
var parentWatchBookkeeper = new TestHelper.Bookkeeper();
126124
var parentWatchConfig = Watch
@@ -139,25 +137,25 @@ void moveRegularFileBetweenNestedDirectories() throws IOException {
139137

140138
var fileWatchBookkeeper = new TestHelper.Bookkeeper();
141139
var fileWatchConfig = Watch
142-
.build(file, WatchScope.PATH_ONLY)
140+
.build(regularFile, WatchScope.PATH_ONLY)
143141
.on(fileWatchBookkeeper);
144142

145143
try (var parentWatch = parentWatchConfig.start();
146144
var child1Watch = child1WatchConfig.start();
147145
var child2Watch = child2WatchConfig.start();
148146
var fileWatch = fileWatchConfig.start()) {
149147

150-
var source = child1.resolve(file.getFileName());
151-
var target = child2.resolve(file.getFileName());
148+
var source = child1.resolve(regularFile.getFileName());
149+
var target = child2.resolve(regularFile.getFileName());
152150
Files.move(source, target);
153151

154-
await("Move should be observed as delete by `parent` watch (file tree)")
155-
.until(() -> parentWatchBookkeeper
156-
.events().kind(DELETED).rootPath(parent).relativePath(parent.relativize(source)).any());
157-
158-
await("Move should be observed as create by `parent` watch (file tree)")
159-
.until(() -> parentWatchBookkeeper
160-
.events().kind(CREATED).rootPath(parent).relativePath(parent.relativize(target)).any());
152+
for (var e : new WatchEvent[] {
153+
new WatchEvent(DELETED, parent, parent.relativize(source)),
154+
new WatchEvent(CREATED, parent, parent.relativize(target))
155+
}) {
156+
await("Move should be observed as delete/create by `parent` watch (file tree): " + e)
157+
.until(() -> parentWatchBookkeeper.events().any(e));
158+
}
161159

162160
await("Move should be observed as delete by `child1` watch (single directory)")
163161
.until(() -> child1WatchBookkeeper
@@ -172,4 +170,82 @@ void moveRegularFileBetweenNestedDirectories() throws IOException {
172170
.events().kind(DELETED).rootPath(source).any());
173171
}
174172
}
173+
174+
@Test
175+
void moveDirectory() throws IOException {
176+
var parent = testDir.getTestDirectory();
177+
var child1 = Files.createDirectories(parent.resolve("from"));
178+
var child2 = Files.createDirectories(parent.resolve("to"));
179+
180+
var directory = Files.createDirectory(child1.resolve("directory"));
181+
var regularFile1 = Files.createFile(directory.resolve("file1.txt"));
182+
var regularFile2 = Files.createFile(directory.resolve("file2.txt"));
183+
184+
var parentWatchBookkeeper = new TestHelper.Bookkeeper();
185+
var parentWatchConfig = Watch
186+
.build(parent, WatchScope.PATH_AND_ALL_DESCENDANTS)
187+
.on(parentWatchBookkeeper);
188+
189+
var child1WatchBookkeeper = new TestHelper.Bookkeeper();
190+
var child1WatchConfig = Watch
191+
.build(child1, WatchScope.PATH_AND_CHILDREN)
192+
.on(child1WatchBookkeeper);
193+
194+
var child2WatchBookkeeper = new TestHelper.Bookkeeper();
195+
var child2WatchConfig = Watch
196+
.build(child2, WatchScope.PATH_AND_CHILDREN)
197+
.on(child2WatchBookkeeper);
198+
199+
var directoryWatchBookkeeper = new TestHelper.Bookkeeper();
200+
var directoryWatchConfig = Watch
201+
.build(directory, WatchScope.PATH_ONLY)
202+
.on(directoryWatchBookkeeper);
203+
204+
try (var parentWatch = parentWatchConfig.start();
205+
var child1Watch = child1WatchConfig.start();
206+
var child2Watch = child2WatchConfig.start();
207+
var fileWatch = directoryWatchConfig.start()) {
208+
209+
var sourceDirectory = child1.resolve(directory.getFileName());
210+
var sourceRegularFile1 = sourceDirectory.resolve(regularFile1.getFileName());
211+
var sourceRegularFile2 = sourceDirectory.resolve(regularFile2.getFileName());
212+
213+
var targetDirectory = child2.resolve(directory.getFileName());
214+
var targetRegularFile1 = targetDirectory.resolve(regularFile1.getFileName());
215+
var targetRegularFile2 = targetDirectory.resolve(regularFile2.getFileName());
216+
217+
Files.move(sourceDirectory, targetDirectory);
218+
219+
for (var e : new WatchEvent[] {
220+
new WatchEvent(DELETED, parent, parent.relativize(sourceDirectory)),
221+
new WatchEvent(CREATED, parent, parent.relativize(targetDirectory)),
222+
// The following events currently *aren't* observed by the
223+
// `parent` watch for the whole file tree: moving a directory
224+
// doesn't trigger events for the deletion/creation of the files
225+
// contained in it (neither using the general default/JDK
226+
// implementation of Watch Service, nor using our special macOS
227+
// implementation).
228+
//
229+
// new WatchEvent(DELETED, parent, parent.relativize(sourceRegularFile1)),
230+
// new WatchEvent(DELETED, parent, parent.relativize(sourceRegularFile2)),
231+
// new WatchEvent(CREATED, parent, parent.relativize(targetRegularFile1)),
232+
// new WatchEvent(CREATED, parent, parent.relativize(targetRegularFile2))
233+
}) {
234+
await("Move should be observed as delete/create by `parent` watch (file tree): " + e)
235+
.until(() -> parentWatchBookkeeper.events().any(e));
236+
}
237+
238+
await("Move should be observed as delete by `child1` watch (single directory)")
239+
.until(() -> child1WatchBookkeeper
240+
.events().kind(DELETED).rootPath(child1).relativePath(child1.relativize(sourceDirectory)).any());
241+
242+
await("Move should be observed as create by `child2` watch (single directory)")
243+
.until(() -> child2WatchBookkeeper
244+
.events().kind(CREATED).rootPath(child2).relativePath(child2.relativize(targetDirectory)).any());
245+
246+
await("Move should be observed as delete by `directory` watch")
247+
.until(() -> directoryWatchBookkeeper
248+
.events().kind(DELETED).rootPath(sourceDirectory).any());
249+
}
250+
}
175251
}

src/test/java/engineering/swat/watch/impl/mac/APIs.java

+35-16
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828

2929
import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCreateFlag.FILE_EVENTS;
3030
import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCreateFlag.NO_DEFER;
31+
import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCreateFlag.USE_CF_TYPES;
32+
import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCreateFlag.USE_EXTENDED_DATA;
3133
import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCreateFlag.WATCH_ROOT;
3234
import static org.awaitility.Awaitility.await;
3335

@@ -52,7 +54,9 @@
5254
import com.sun.jna.Pointer;
5355
import com.sun.jna.platform.mac.CoreFoundation;
5456
import com.sun.jna.platform.mac.CoreFoundation.CFArrayRef;
57+
import com.sun.jna.platform.mac.CoreFoundation.CFDictionaryRef;
5558
import com.sun.jna.platform.mac.CoreFoundation.CFIndex;
59+
import com.sun.jna.platform.mac.CoreFoundation.CFNumberRef;
5660
import com.sun.jna.platform.mac.CoreFoundation.CFStringRef;
5761

5862
import engineering.swat.watch.TestDirectory;
@@ -78,13 +82,13 @@ void smokeTest() throws IOException {
7882
var paths = ConcurrentHashMap.<String> newKeySet();
7983

8084
var s = test.getTestDirectory().toString();
81-
var handler = (MinimalWorkingExample.EventHandler) (path, flags, id) -> {
85+
var handler = (MinimalWorkingExample.EventHandler) (path, inode, flags, id) -> {
8286
synchronized (ready) {
8387
while (!ready.get()) {
8488
try {
8589
ready.wait();
8690
} catch (InterruptedException e) {
87-
LOGGER.error("Unexpected interrupt. Test likely to fail. Event ignored ({}).", prettyPrint(path, flags, id));
91+
LOGGER.error("Unexpected interrupt. Test likely to fail. Event ignored ({}).", prettyPrint(path, inode, flags, id));
8892
Thread.currentThread().interrupt();
8993
return;
9094
}
@@ -93,7 +97,7 @@ void smokeTest() throws IOException {
9397
paths.remove(path);
9498
};
9599

96-
try (var mwe = new MinimalWorkingExample(s, handler)) {
100+
try (var mwe = new MinimalWorkingExample(s, handler, true)) {
97101
var dir = test.getTestDirectory().toRealPath();
98102
paths.add(Files.writeString(dir.resolve("a.txt"), "foo").toString());
99103
paths.add(Files.writeString(dir.resolve("b.txt"), "bar").toString());
@@ -111,33 +115,34 @@ void smokeTest() throws IOException {
111115

112116
public static void main(String[] args) throws IOException {
113117
var s = args[0];
114-
var handler = (MinimalWorkingExample.EventHandler) (path, flags, id) -> {
115-
LOGGER.info(prettyPrint(path, flags, id));
118+
var handler = (MinimalWorkingExample.EventHandler) (path, inode, flags, id) -> {
119+
LOGGER.info(prettyPrint(path, inode, flags, id));
116120
};
121+
var useExtendedData = args.length >= 2 && Boolean.parseBoolean(args[1]);
117122

118-
try (var mwe = new MinimalWorkingExample(s, handler)) {
123+
try (var mwe = new MinimalWorkingExample(s, handler, useExtendedData)) {
119124
// Block the program from terminating until `ENTER` is pressed
120125
new BufferedReader(new InputStreamReader(System.in)).readLine();
121126
}
122127
}
123128

124-
private static String prettyPrint(String path, int flags, long id) {
129+
private static String prettyPrint(String path, long inode, int flags, long id) {
125130
var flagsPrettyPrinted = Stream
126131
.of(FSEventStreamEventFlag.values())
127132
.filter(f -> (f.mask & flags) == f.mask)
128133
.map(Object::toString)
129134
.collect(Collectors.joining(", "));
130135

131-
var format = "path: \"%s\", flags: [%s], id: %s";
132-
return String.format(format, path, flagsPrettyPrinted, id);
136+
var format = "path: \"%s\", inode: %s, flags: [%s], id: %s";
137+
return String.format(format, path, inode, flagsPrettyPrinted, id);
133138
}
134139

135140
private static class MinimalWorkingExample implements Closeable {
136141
private FileSystemEvents.FSEventStreamCallback callback;
137142
private Pointer stream;
138143
private Pointer queue;
139144

140-
public MinimalWorkingExample(String s, EventHandler handler) {
145+
public MinimalWorkingExample(String s, EventHandler handler, boolean useExtendedData) {
141146

142147
// Allocate singleton array of paths
143148
CFStringRef pathToWatch = CFStringRef.createCFString(s);
@@ -154,11 +159,24 @@ public MinimalWorkingExample(String s, EventHandler handler) {
154159

155160
// Allocate callback
156161
this.callback = (x1, x2, x3, x4, x5, x6) -> {
157-
var paths = x4.getStringArray(0, (int) x3);
158-
var flags = x5.getIntArray(0, (int) x3);
159-
var ids = x6.getLongArray(0, (int) x3);
162+
var paths = x4.getStringArray(0, (int) x3);
163+
var inodes = new long[(int) x3];
164+
var flags = x5.getIntArray(0, (int) x3);
165+
var ids = x6.getLongArray(0, (int) x3);
166+
167+
if (useExtendedData) {
168+
var extendedData = new CFArrayRef(x4);
169+
for (int i = 0; i < x3; i++) {
170+
var dictionary = new CFDictionaryRef(extendedData.getValueAtIndex(i));
171+
var dictionaryPath = dictionary.getValue(FileSystemEvents.kFSEventStreamEventExtendedDataPathKey);
172+
var dictionaryInode = dictionary.getValue(FileSystemEvents.kFSEventStreamEventExtendedFileIDKey);
173+
paths[i] = dictionaryPath == null ? null : new CFStringRef(dictionaryPath).stringValue();
174+
inodes[i] = dictionaryInode == null ? 0 : new CFNumberRef(dictionaryInode).longValue();
175+
}
176+
}
177+
160178
for (int i = 0; i < x3; i++) {
161-
handler.handle(paths[i], flags[i], ids[i]);
179+
handler.handle(paths[i], inodes[i], flags[i], ids[i]);
162180
}
163181
};
164182

@@ -170,7 +188,8 @@ public MinimalWorkingExample(String s, EventHandler handler) {
170188
pathsToWatch,
171189
FSE.FSEventsGetCurrentEventId(),
172190
0.15,
173-
NO_DEFER.mask | WATCH_ROOT.mask | FILE_EVENTS.mask);
191+
NO_DEFER.mask | WATCH_ROOT.mask | FILE_EVENTS.mask |
192+
(useExtendedData ? USE_EXTENDED_DATA.mask | USE_CF_TYPES.mask : 0));
174193

175194
// Deallocate array of paths
176195
pathsToWatch.release();
@@ -203,7 +222,7 @@ public void close() throws IOException {
203222

204223
@FunctionalInterface
205224
private static interface EventHandler {
206-
void handle(String path, int flags, long id);
225+
void handle(String path, long inode, int flags, long id);
207226
}
208227
}
209228
}

0 commit comments

Comments
 (0)