Skip to content

Commit 948775b

Browse files
authored
Merge pull request #16089 from codeconsole/fix/directory-watcher-fallback-8.0.x
Fall back to the WatchService watcher instead of polling on macOS
2 parents 4bc5bd8 + ab2b2af commit 948775b

3 files changed

Lines changed: 334 additions & 23 deletions

File tree

grails-bootstrap/build.gradle

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919

2020
import org.apache.tools.ant.filters.ReplaceTokens
21+
import org.gradle.process.CommandLineArgumentProvider
2122

2223
plugins {
2324
id 'groovy'
@@ -43,6 +44,15 @@ cliArtifact {
4344
defaultDependencies = false
4445
}
4546

47+
configurations {
48+
// the optional native macOS watcher library, resolved for DirectoryWatcherSpec's isolated class
49+
// loaders without joining any compile or runtime classpath
50+
optionalWatcher {
51+
canBeConsumed = false
52+
canBeResolved = true
53+
}
54+
}
55+
4656
dependencies {
4757
implementation platform(project(':grails-bom'))
4858

@@ -88,13 +98,33 @@ dependencies {
8898
// Testing
8999
testImplementation 'org.slf4j:slf4j-simple'
90100
testImplementation 'org.spockframework:spock-core'
101+
102+
// JNA without directory-watcher is the classpath shape DirectoryWatcherSpec pins: applications
103+
// routinely pick JNA up transitively, and it must not be mistaken for native watcher support.
104+
testImplementation 'net.java.dev.jna:jna'
105+
106+
// Resolved but deliberately kept off the test runtime classpath, so the watcher selected by
107+
// default in tests matches an application that has not opted into the native implementation.
108+
// DirectoryWatcherSpec builds isolated class loaders from these jars to cover the opted-in paths.
109+
optionalWatcher platform(project(':grails-bom'))
110+
optionalWatcher 'io.methvin:directory-watcher'
91111
}
92112

93113
processResources {
94114
inputs.property 'version', version
95115
filter(ReplaceTokens, tokens: [version: version])
96116
}
97117

118+
tasks.named('test') {
119+
// Hand the optional watcher jars to DirectoryWatcherSpec as a path rather than a classpath entry.
120+
// Resolution stays deferred to execution time so configuring this project does not resolve it.
121+
Provider<String> optionalWatcherPath = configurations.named('optionalWatcher').map { it.asPath }
122+
inputs.files(configurations.named('optionalWatcher')).withPropertyName('optionalWatcher')
123+
jvmArgumentProviders.add({
124+
["-Dgrails.test.optionalWatcherClasspath=${optionalWatcherPath.get()}".toString()]
125+
} as CommandLineArgumentProvider)
126+
}
127+
98128
apply {
99129
from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle')
100130
from rootProject.layout.projectDirectory.file('gradle/test-config.gradle')

grails-bootstrap/src/main/groovy/org/grails/io/watch/DirectoryWatcher.java

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -45,32 +45,60 @@ public class DirectoryWatcher extends Thread {
4545
*/
4646
public DirectoryWatcher() {
4747
setDaemon(true);
48-
AbstractDirectoryWatcher directoryWatcherDelegate;
49-
try {
50-
if (System.getProperty("os.name").equals("Mac OS X")) {
51-
Boolean jnaAvailable = false;
52-
try {
53-
Class.forName("com.sun.jna.Pointer");
54-
jnaAvailable = true;
55-
} catch (ClassNotFoundException e) {
56-
if (LOG.isWarnEnabled()) {
57-
LOG.warn("Error Initializing Native OS X File Event Watcher. Add JNA to classpath for Faster File Watching performance.");
58-
}
59-
60-
}
61-
if (jnaAvailable) {
62-
directoryWatcherDelegate = (AbstractDirectoryWatcher) Class.forName("org.grails.io.watch.MacOsWatchServiceDirectoryWatcher").getDeclaredConstructor().newInstance();
63-
} else {
64-
directoryWatcherDelegate = (AbstractDirectoryWatcher) Class.forName("org.grails.io.watch.WatchServiceDirectoryWatcher").getDeclaredConstructor().newInstance();
65-
}
66-
} else {
67-
directoryWatcherDelegate = (AbstractDirectoryWatcher) Class.forName("org.grails.io.watch.WatchServiceDirectoryWatcher").getDeclaredConstructor().newInstance();
48+
this.directoryWatcherDelegate = createDelegate();
49+
}
50+
51+
/**
52+
* Selects the best available watcher implementation.
53+
*
54+
* <p>On macOS the native FSEvents based watcher is preferred, but it requires the optional
55+
* {@code io.methvin:directory-watcher} dependency. When that isn't available the JDK
56+
* {@link java.nio.file.WatchService} is used instead. Polling is only a last resort.</p>
57+
*
58+
* @return the watcher to delegate to
59+
*/
60+
private static AbstractDirectoryWatcher createDelegate() {
61+
if (System.getProperty("os.name").equals("Mac OS X")) {
62+
AbstractDirectoryWatcher macOsWatcher = createMacOsWatcher();
63+
if (macOsWatcher != null) {
64+
return macOsWatcher;
6865
}
66+
}
67+
try {
68+
return new WatchServiceDirectoryWatcher();
69+
} catch (Throwable e) {
70+
LOG.warn("Could not create a WatchService based directory watcher. Falling back to PollingDirectoryWatcher.", e);
71+
return new PollingDirectoryWatcher();
72+
}
73+
}
74+
75+
/**
76+
* @return the native macOS watcher, or {@code null} if it is unavailable
77+
*/
78+
private static AbstractDirectoryWatcher createMacOsWatcher() {
79+
try {
80+
// MacOsWatchServiceDirectoryWatcher delegates to io.methvin's MacOSXListeningWatchService,
81+
// an optional dependency of this module. Probe for that class rather than for JNA: JNA is
82+
// frequently present transitively (Testcontainers, docker-java, ...) without the watcher
83+
// library, and using it as the signal sends those applications down a load that can't succeed.
84+
// A LinkageError means the class is present but its own dependencies (JNA) are not, which is
85+
// equally unusable, so treat both as simply unavailable.
86+
Class.forName("io.methvin.watchservice.MacOSXListeningWatchService");
87+
} catch (ClassNotFoundException | LinkageError e) {
88+
// Warn rather than debug: the JDK supplies no native WatchService on macOS, so the fallback
89+
// polls and file changes take seconds to be noticed. The message names the one dependency
90+
// that fixes that, and adding it silences this. The cause is debug detail, not the point.
91+
LOG.warn("Native macOS file event watching is unavailable, so the JDK WatchService is used instead, " +
92+
"which polls on macOS. Add 'io.methvin:directory-watcher' to the classpath for event driven file watching.");
93+
LOG.debug("io.methvin.watchservice.MacOSXListeningWatchService could not be loaded.", e);
94+
return null;
95+
}
96+
try {
97+
return (AbstractDirectoryWatcher) Class.forName("org.grails.io.watch.MacOsWatchServiceDirectoryWatcher").getDeclaredConstructor().newInstance();
6998
} catch (Throwable e) {
70-
LOG.info("Exception while trying to load WatchServiceDirectoryWatcher (this is probably Java 6 and WatchService isn't available). Falling back to PollingDirectoryWatcher.", e);
71-
directoryWatcherDelegate = new PollingDirectoryWatcher();
99+
LOG.warn("Could not create the native macOS directory watcher. Falling back to the JDK WatchService.", e);
100+
return null;
72101
}
73-
this.directoryWatcherDelegate = directoryWatcherDelegate;
74102
}
75103

76104
/**
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* https://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.grails.io.watch
20+
21+
import java.lang.reflect.InvocationHandler
22+
import java.lang.reflect.Proxy
23+
import java.nio.file.Path
24+
import java.util.concurrent.CopyOnWriteArrayList
25+
26+
import spock.lang.Requires
27+
import spock.lang.Specification
28+
import spock.lang.TempDir
29+
import spock.util.concurrent.PollingConditions
30+
import spock.util.environment.RestoreSystemProperties
31+
32+
/**
33+
* Tests that a {@link DirectoryWatcher} reports file changes on the classpaths an application can
34+
* present, exercised through the watcher's public API.
35+
*
36+
* <p>The native macOS watcher needs {@code io.methvin:directory-watcher}, which this module declares
37+
* {@code compileOnly}. The test runtime therefore does not carry it — matching an application that
38+
* has not opted in — while {@code net.java.dev.jna:jna} is present, reproducing the common case of
39+
* JNA arriving transitively (Testcontainers, docker-java, ...) on its own. Cases that need the
40+
* optional library run in a class loader assembled from the {@code optionalWatcher} configuration.
41+
* Those use reflection only to cross the class loader boundary; every call is to a public method.</p>
42+
*/
43+
@RestoreSystemProperties
44+
class DirectoryWatcherSpec extends Specification {
45+
46+
private static final String MAC_OS = 'Mac OS X'
47+
48+
/**
49+
* Long enough that a watcher which only wakes on this interval cannot report inside the timeouts
50+
* used here, so a report proves the watcher is event driven rather than polling.
51+
*/
52+
private static final long NON_POLLING_SLEEP_TIME = 120_000
53+
54+
@TempDir
55+
Path watchedDirectory
56+
57+
void 'a change to a watched file is reported'() {
58+
given:
59+
List<String> changed = watchForChanges(new DirectoryWatcher(), 1000)
60+
61+
when:
62+
modifyWatchedFile()
63+
64+
then:
65+
new PollingConditions(timeout: 60).eventually {
66+
assert changed.contains('watched.txt')
67+
}
68+
}
69+
70+
void 'a change is reported without waiting for a poll interval'() {
71+
given: 'a macOS classpath carrying JNA but not the optional watcher library'
72+
System.setProperty('os.name', MAC_OS)
73+
74+
and: 'a sleep interval far longer than the timeout below'
75+
List<String> changed = watchForChanges(new DirectoryWatcher(), NON_POLLING_SLEEP_TIME)
76+
77+
when:
78+
modifyWatchedFile()
79+
80+
then: 'JNA alone must not downgrade the watcher to polling'
81+
new PollingConditions(timeout: 40).eventually {
82+
assert changed.contains('watched.txt')
83+
}
84+
}
85+
86+
// Needs a real macOS host, not just os.name: registering a directory calls through to the Carbon
87+
// framework, which JNA can only bind there. The linkage test below stays cross-platform because it
88+
// falls back before any native registration happens.
89+
@Requires({ os.macOs && DirectoryWatcherSpec.optionalWatcherJars() })
90+
void 'a change is reported when the optional native library is available'() {
91+
given:
92+
System.setProperty('os.name', MAC_OS)
93+
List<String> changed = watchForChangesIn(isolatedLoader(true), 1000)
94+
95+
when:
96+
modifyWatchedFile()
97+
98+
then:
99+
new PollingConditions(timeout: 60).eventually {
100+
assert changed.contains('watched.txt')
101+
}
102+
}
103+
104+
@Requires({ DirectoryWatcherSpec.optionalWatcherJars() })
105+
void 'a change is reported when the optional library is present but cannot link'() {
106+
given: 'directory-watcher present without the JNA it links against, so loading it fails'
107+
System.setProperty('os.name', MAC_OS)
108+
109+
when: 'the watcher is constructed and started'
110+
List<String> changed = watchForChangesIn(isolatedLoader(false), 1000)
111+
modifyWatchedFile()
112+
113+
then: 'the linkage failure degrades to a working watcher rather than failing construction'
114+
new PollingConditions(timeout: 60).eventually {
115+
assert changed.contains('watched.txt')
116+
}
117+
}
118+
119+
void 'macOS without the optional library says how to restore event driven watching'() {
120+
given: 'the classpath an application has by default, where the optional library is absent'
121+
System.setProperty('os.name', MAC_OS)
122+
123+
when:
124+
String logged = captureStandardError { registerForCleanup(new DirectoryWatcher()) }
125+
126+
then: 'the fallback is announced, not silent, and names the dependency that resolves it'
127+
logged.contains('WARN')
128+
logged.contains('io.methvin:directory-watcher')
129+
}
130+
131+
void 'a platform with a native WatchService says nothing about the optional library'() {
132+
given: 'a platform whose JDK WatchService is already event driven'
133+
System.setProperty('os.name', 'Linux')
134+
135+
when:
136+
String logged = captureStandardError { registerForCleanup(new DirectoryWatcher()) }
137+
138+
then: 'the advice is macOS only, so it must not reach anyone it cannot help'
139+
!logged.contains('io.methvin:directory-watcher')
140+
}
141+
142+
/**
143+
* Captures what an application would see on the console. slf4j-simple resolves
144+
* {@code System.err} on each write, so replacing it here redirects the watcher's own logging.
145+
*/
146+
private static String captureStandardError(Closure<?> work) {
147+
PrintStream original = System.err
148+
ByteArrayOutputStream captured = new ByteArrayOutputStream()
149+
System.setErr(new PrintStream(captured, true))
150+
try {
151+
work.call()
152+
}
153+
finally {
154+
System.setErr(original)
155+
}
156+
captured.toString()
157+
}
158+
159+
private File modifyWatchedFile() {
160+
File watched = new File(watchedDirectory.toFile(), 'watched.txt')
161+
watched.text = "modified ${System.nanoTime()}"
162+
watched
163+
}
164+
165+
/**
166+
* Starts the watcher on the temporary directory and collects the names of files reported.
167+
*/
168+
private List<String> watchForChanges(DirectoryWatcher watcher, long sleepTime) {
169+
File watched = new File(watchedDirectory.toFile(), 'watched.txt')
170+
watched.text = 'initial'
171+
172+
List<String> changed = new CopyOnWriteArrayList<>()
173+
watcher.sleepTime = sleepTime
174+
watcher.addListener(new DirectoryWatcher.FileChangeListener() {
175+
void onChange(File file) { changed << file.name }
176+
177+
void onNew(File file) { changed << file.name }
178+
})
179+
watcher.addWatchDirectory(watchedDirectory.toFile(), 'txt')
180+
watcher.start()
181+
registerForCleanup(watcher)
182+
// let the watcher register before the file is touched
183+
Thread.sleep(1000)
184+
changed
185+
}
186+
187+
/**
188+
* The same as {@link #watchForChanges}, for a watcher loaded by another class loader. Reflection
189+
* bridges the loader boundary only; every member used is part of the public API.
190+
*/
191+
private List<String> watchForChangesIn(ClassLoader loader, long sleepTime) {
192+
File watched = new File(watchedDirectory.toFile(), 'watched.txt')
193+
watched.text = 'initial'
194+
195+
Class<?> watcherClass = loader.loadClass(DirectoryWatcher.name)
196+
Class<?> listenerClass = loader.loadClass(DirectoryWatcher.FileChangeListener.name)
197+
Object watcher = watcherClass.getDeclaredConstructor().newInstance()
198+
199+
List<String> changed = new CopyOnWriteArrayList<>()
200+
Object listener = Proxy.newProxyInstance(loader, [listenerClass] as Class[], { proxy, method, arguments ->
201+
if (method.name in ['onChange', 'onNew']) {
202+
changed << ((File) arguments[0]).name
203+
}
204+
null
205+
} as InvocationHandler)
206+
207+
watcherClass.getMethod('setSleepTime', long).invoke(watcher, sleepTime)
208+
watcherClass.getMethod('addListener', listenerClass).invoke(watcher, listener)
209+
watcherClass.getMethod('addWatchDirectory', File, String).invoke(watcher, watchedDirectory.toFile(), 'txt')
210+
watcherClass.getMethod('start').invoke(watcher)
211+
registerForCleanup(watcher, watcherClass.getMethod('setActive', boolean))
212+
Thread.sleep(1000)
213+
changed
214+
}
215+
216+
/**
217+
* Builds a class loader carrying the optional watcher library, with JNA either present or absent.
218+
* The platform loader as parent keeps this JVM's application classpath out of the picture.
219+
*/
220+
private static ClassLoader isolatedLoader(boolean includeJna) {
221+
List<File> entries = System.getProperty('java.class.path')
222+
.split(File.pathSeparator)
223+
.collect { new File(it) }
224+
// directory-watcher depends on JNA, so the exclusion has to cover the optional jars too
225+
entries.addAll(optionalWatcherJars())
226+
URL[] urls = entries
227+
.findAll { includeJna || !(it.name ==~ /jna(-platform)?-\d.*\.jar/) }
228+
.collect { it.toURI().toURL() }
229+
new URLClassLoader(urls, ClassLoader.platformClassLoader)
230+
}
231+
232+
static List<File> optionalWatcherJars() {
233+
String path = System.getProperty('grails.test.optionalWatcherClasspath')
234+
path ? path.split(File.pathSeparator).collect { new File(it) }.findAll { it.exists() } : []
235+
}
236+
237+
private final List<Closure> cleanupTasks = []
238+
239+
private void registerForCleanup(Object watcher, java.lang.reflect.Method setActive = null) {
240+
cleanupTasks << {
241+
if (setActive) {
242+
setActive.invoke(watcher, false)
243+
}
244+
else {
245+
((DirectoryWatcher) watcher).active = false
246+
}
247+
}
248+
}
249+
250+
void cleanup() {
251+
cleanupTasks.each { it.call() }
252+
}
253+
}

0 commit comments

Comments
 (0)