Skip to content

Improved macOS support: Support for moving/renaming files #44

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
13 changes: 10 additions & 3 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,17 @@ 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: jna
- 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
Expand All @@ -27,7 +34,7 @@ jobs:
cache: 'maven'

- name: test
run: mvn -B clean test
run: mvn -B clean test -DargLine="-Dengineering.swat.java-watch.mac=${{ matrix.os.mac-backend }}"
env:
DELAY_FACTOR: 3

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,13 @@ try(var active = watcherSetup.start()) {
// no new events will be scheduled on the threadpool
```

## Internals
## 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 [file system event streams](https://developer.apple.com/documentation/coreservices/file_system_events?language=objc) (using JNA).
Generally, it offers better performance than the JDK default implementation (because the latter uses a polling loop to detect changes only once every two seconds).
To force the library to use the JDK default implementation on macOS, set system property `engineering.swat.watch.impl` to `default`.
To force the library to use the JDK default implementation on macOS, set system property `engineering.swat.java-watch.mac` to `jdk`.

## Related work

Expand Down
3 changes: 2 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
<checkerframework.version>3.49.2</checkerframework.version>
<junit.version>5.12.2</junit.version>
<log4j.version>2.24.3</log4j.version>
<jna.version>5.16.0</jna.version>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
Expand Down Expand Up @@ -231,7 +232,7 @@
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna-platform</artifactId>
<version>5.16.0</version>
<version>${jna.version}</version>
</dependency>
</dependencies>

Expand Down
24 changes: 13 additions & 11 deletions src/main/java/engineering/swat/watch/impl/jdk/JDKPoller.java
Original file line number Diff line number Diff line change
Expand Up @@ -190,20 +190,22 @@ public Watchable newWatchable(Path path) {
static final Platform CURRENT = current(); // Assumption: the platform doesn't change

private static Platform current() {
var key = "engineering.swat.watch.impl";
var val = System.getProperty(key);
if (val != null) {
if (val.equals("mac")) {
return MAC;
} else if (val.equals("default")) {
return DEFAULT;
} else {
logger.warn("Unexpected value \"{}\" for system property \"{}\". Using value \"default\" instead.", val, key);
return DEFAULT;
if (com.sun.jna.Platform.isMac()) {
var key = "engineering.swat.java-watch.mac";
var val = System.getProperty(key);
if (val != null) {
if (val.equals("jna")) {
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 com.sun.jna.Platform.isMac() ? MAC : DEFAULT;
return DEFAULT;
}

static Platform get() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
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;
Expand All @@ -41,6 +42,7 @@

import java.io.Closeable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;

Expand Down Expand Up @@ -153,6 +155,19 @@ public void callback(Pointer streamRef, Pointer clientCallBackInfo,
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);
}
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/test/java/engineering/swat/watch/SingleFileTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
104 changes: 90 additions & 14 deletions src/test/java/engineering/swat/watch/SmokeTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;


Expand Down Expand Up @@ -115,12 +114,11 @@ void watchSingleFile() throws IOException {
}

@Test
@Disabled
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
Expand All @@ -139,25 +137,25 @@ 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();
var child1Watch = child1WatchConfig.start();
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
Expand All @@ -172,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());
}
}
}
Loading