|
| 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