001/* 002 * This file is part of McIDAS-V 003 * 004 * Copyright 2007-2025 005 * Space Science and Engineering Center (SSEC) 006 * University of Wisconsin - Madison 007 * 1225 W. Dayton Street, Madison, WI 53706, USA 008 * https://www.ssec.wisc.edu/mcidas/ 009 * 010 * All Rights Reserved 011 * 012 * McIDAS-V is built on Unidata's IDV and SSEC's VisAD libraries, and 013 * some McIDAS-V source code is based on IDV and VisAD source code. 014 * 015 * McIDAS-V is free software; you can redistribute it and/or modify 016 * it under the terms of the GNU Lesser Public License as published by 017 * the Free Software Foundation; either version 3 of the License, or 018 * (at your option) any later version. 019 * 020 * McIDAS-V is distributed in the hope that it will be useful, 021 * but WITHOUT ANY WARRANTY; without even the implied warranty of 022 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 023 * GNU Lesser Public License for more details. 024 * 025 * You should have received a copy of the GNU Lesser Public License 026 * along with this program. If not, see https://www.gnu.org/licenses/. 027 */ 028 029package edu.wisc.ssec.mcidasv.util.pathwatcher; 030 031import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE; 032import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE; 033import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; 034import static java.nio.file.StandardWatchEventKinds.OVERFLOW; 035 036import static edu.wisc.ssec.mcidasv.util.CollectionHelpers.concurrentMap; 037import static edu.wisc.ssec.mcidasv.util.CollectionHelpers.concurrentSet; 038 039import java.io.IOException; 040 041import java.nio.file.FileSystem; 042import java.nio.file.FileSystems; 043import java.nio.file.Files; 044import java.nio.file.Path; 045import java.nio.file.PathMatcher; 046import java.nio.file.Paths; 047import java.nio.file.WatchEvent; 048import java.nio.file.WatchKey; 049import java.nio.file.WatchService; 050 051import java.util.Arrays; 052import java.util.Map; 053import java.util.Set; 054import java.util.concurrent.atomic.AtomicBoolean; 055import java.util.stream.Collectors; 056 057import org.slf4j.Logger; 058import org.slf4j.LoggerFactory; 059 060// Adapted from https://gist.github.com/hindol-viz/394ebc553673e2cd0699 061 062/** 063 * A simple class which can monitor files and notify interested parties 064 * (i.e. listeners) of file changes. 065 * 066 * This class is kept lean by only keeping methods that are actually being 067 * called. 068 */ 069public class SimpleDirectoryWatchService implements DirectoryWatchService, 070 Runnable 071{ 072 073 /** Logging object. */ 074 private static final Logger logger = 075 LoggerFactory.getLogger(SimpleDirectoryWatchService.class); 076 077 /** 078 * {@code WatchService} used to monitor changes in various 079 * {@link Path Paths}. 080 */ 081 private final WatchService mWatchService; 082 083 /** Whether or not this {@link DirectoryWatchService} is running. */ 084 private final AtomicBoolean mIsRunning; 085 086 /** 087 * Mapping of monitoring {@literal "registration"} keys to the 088 * {@link Path} that it will be watching. 089 */ 090 private final Map<WatchKey, Path> mWatchKeyToDirPathMap; 091 092 /** 093 * Mapping of {@link Path Paths} to the {@link Set} of 094 * {@link OnFileChangeListener OnFileChangeListeners} listening for 095 * changes to the associated {@code Path}. 096 */ 097 private final Map<Path, Set<OnFileChangeListener>> mDirPathToListenersMap; 098 099 /** 100 * Mapping of {@link OnFileChangeListener OnFileChangeListeners} to the 101 * {@link Set} of patterns being used to observe changes in 102 * {@link Path Paths} of interest. 103 */ 104 private final Map<OnFileChangeListener, Set<PathMatcher>> 105 mListenerToFilePatternsMap; 106 107 /** 108 * A simple no argument constructor for creating a 109 * {@code SimpleDirectoryWatchService}. 110 * 111 * @throws IOException If an I/O error occurs. 112 */ 113 public SimpleDirectoryWatchService() throws IOException { 114 mWatchService = FileSystems.getDefault().newWatchService(); 115 mIsRunning = new AtomicBoolean(false); 116 mWatchKeyToDirPathMap = concurrentMap(); 117 mDirPathToListenersMap = concurrentMap(); 118 mListenerToFilePatternsMap = concurrentMap(); 119 } 120 121 /** 122 * Utility method used to make {@literal "valid"} casts of the given 123 * {@code event} to a specific type of {@link WatchEvent}. 124 * 125 * @param <T> Type to which {@code event} will be casted. 126 * @param event Event to cast. 127 * 128 * @return {@code event} casted to {@code WatchEvent<T>}. 129 */ 130 @SuppressWarnings("unchecked") 131 private static <T> WatchEvent<T> cast(WatchEvent<?> event) { 132 return (WatchEvent<T>)event; 133 } 134 135 /** 136 * Returns a {@link PathMatcher} that performs {@literal "glob"} matches 137 * with the given {@code globPattern} against the {@code String} 138 * representation of {@link Path} objects. 139 * 140 * @param globPattern Pattern to match against. {@code null} or empty 141 * {@code String} values will be converted to {@code *}. 142 * 143 * @return Path matching object for the given {@code globPattern}. 144 * 145 * @throws IOException if there was a problem creating the 146 * {@code PathMatcher}. 147 */ 148 private static PathMatcher matcherForGlobExpression(String globPattern) 149 throws IOException 150 { 151 if ((globPattern == null) || globPattern.isEmpty()) { 152 globPattern = "*"; 153 } 154 155 return FileSystems.getDefault().getPathMatcher("glob:"+globPattern); 156 } 157 158 /** 159 * Check the given {@code input} {@link Path} against the given {@code 160 * pattern}. 161 * 162 * @param input Path to check. 163 * @param pattern Pattern to check against. Cannot be {@code null}. 164 * 165 * @return Whether or not {@code input} matches {@code pattern}. 166 */ 167 public static boolean matches(Path input, PathMatcher pattern) { 168 return pattern.matches(input); 169 } 170 171 /** 172 * Check the given {@code input} {@link Path} against <i>all</i> of the 173 * specified {@code patterns}. 174 * 175 * @param input Path to check. 176 * @param patterns {@link Set} of patterns to attempt to match 177 * {@code input} against. Cannot be {@code null}. 178 * 179 * @return Whether or not {@code input} matches any of the given 180 * {@code patterns}. 181 */ 182 private static boolean matchesAny(Path input, Set<PathMatcher> patterns) { 183 for (PathMatcher pattern : patterns) { 184 if (matches(input, pattern)) { 185 return true; 186 } 187 } 188 189 return false; 190 } 191 192 /** 193 * Get the path associated with the given {@link WatchKey}. 194 * 195 * @param key {@code WatchKey} whose corresponding {@link Path} is being 196 * requested. 197 * 198 * @return Either the correspond {@code Path} or {@code null}. 199 */ 200 private Path getDirPath(WatchKey key) { 201 return mWatchKeyToDirPathMap.get(key); 202 } 203 204 /** 205 * Get the {@link OnFileChangeListener OnFileChangeListeners} associated 206 * with the given {@code path}. 207 * 208 * @param path Path whose listeners should be returned. Cannot be 209 * {@code null}. 210 * 211 * @return Either the {@link Set} of listeners associated with {@code path} 212 * or {@code null}. 213 */ 214 private Set<OnFileChangeListener> getListeners(Path path) { 215 return mDirPathToListenersMap.get(path); 216 } 217 218 /** 219 * Get the {@link Set} of patterns associated with the given 220 * {@link OnFileChangeListener}. 221 * 222 * @param listener Listener of interest. 223 * 224 * @return Either the {@code Set} of patterns associated with 225 * {@code listener} or {@code null}. 226 */ 227 private Set<PathMatcher> getPatterns(OnFileChangeListener listener) { 228 return mListenerToFilePatternsMap.get(listener); 229 } 230 231 /** 232 * Get the {@link Path} associated with the given 233 * {@link OnFileChangeListener}. 234 * 235 * @param listener Listener whose path is requested. 236 * 237 * @return Either the {@code Path} associated with {@code listener} or 238 * {@code null}. 239 */ 240 private Path getDir(OnFileChangeListener listener) { 241 242 Set<Map.Entry<Path, Set<OnFileChangeListener>>> entries = 243 mDirPathToListenersMap.entrySet(); 244 245 Path result = null; 246 for (Map.Entry<Path, Set<OnFileChangeListener>> entry : entries) { 247 Set<OnFileChangeListener> listeners = entry.getValue(); 248 if (listeners.contains(listener)) { 249 result = entry.getKey(); 250 break; 251 } 252 } 253 254 return result; 255 } 256 257 /** 258 * Get the monitoring {@literal "registration"} key associated with the 259 * given {@link Path}. 260 * 261 * @param dir {@code Path} whose {@link WatchKey} is requested. 262 * 263 * @return Either the {@code WatchKey} corresponding to {@code dir} or 264 * {@code null}. 265 */ 266 private WatchKey getWatchKey(Path dir) { 267 Set<Map.Entry<WatchKey, Path>> entries = 268 mWatchKeyToDirPathMap.entrySet(); 269 270 WatchKey key = null; 271 for (Map.Entry<WatchKey, Path> entry : entries) { 272 if (entry.getValue().equals(dir)) { 273 key = entry.getKey(); 274 break; 275 } 276 } 277 278 return key; 279 } 280 281 /** 282 * Get the {@link Set} of 283 * {@link OnFileChangeListener OnFileChangeListeners} that should be 284 * notified that {@code file} has changed. 285 * 286 * @param dir Directory containing {@code file}. 287 * @param file File that changed. 288 * 289 * @return {@code Set} of listeners that should be notified that 290 * {@code file} has changed. 291 */ 292 private Set<OnFileChangeListener> matchedListeners(Path dir, Path file) { 293 return getListeners(dir) 294 .stream() 295 .filter(listener -> matchesAny(file, getPatterns(listener))) 296 .collect(Collectors.toSet()); 297 } 298 299 /** 300 * Method responsible for notifying listeners when a file matching their 301 * relevant pattern has changed. 302 * 303 * Note: {@literal "change"} means one of: 304 * <ul> 305 * <li>file creation</li> 306 * <li>file removal</li> 307 * <li>file contents changing</li> 308 * </ul> 309 * 310 * @param key {@link #mWatchService} {@literal "registration"} key for 311 * one of the {@link Path Paths} being watched. Cannot be 312 * {@code null}. 313 * 314 * @see #run() 315 */ 316 private void notifyListeners(WatchKey key) { 317 for (WatchEvent<?> event : key.pollEvents()) { 318 WatchEvent.Kind eventKind = event.kind(); 319 320 // Overflow occurs when the watch event queue is overflown 321 // with events. 322 if (eventKind.equals(OVERFLOW)) { 323 // TODO: Notify all listeners. 324 return; 325 } 326 327 WatchEvent<Path> pathEvent = cast(event); 328 Path file = pathEvent.context(); 329 String completePath = getDirPath(key).resolve(file).toString(); 330 331 if (eventKind.equals(ENTRY_CREATE)) { 332 matchedListeners(getDirPath(key), file) 333 .forEach(l -> l.onFileCreate(completePath)); 334 } else if (eventKind.equals(ENTRY_MODIFY)) { 335 matchedListeners(getDirPath(key), file) 336 .forEach(l -> l.onFileModify(completePath)); 337 } else if (eventKind.equals(ENTRY_DELETE)) { 338 matchedListeners(getDirPath(key), file) 339 .forEach(l -> l.onFileDelete(completePath)); 340 } 341 } 342 } 343 344 /** 345 * Method responsible for notifying listeners when the path they are 346 * watching has been deleted (or otherwise {@literal "invalidated"} 347 * somehow). 348 * 349 * @param key Key that has become invalid. Cannot be {@code null}. 350 */ 351 private void notifyListenersOfInvalidation(WatchKey key) { 352 Path dir = getDirPath(key); 353 getListeners(dir).forEach(l -> l.onWatchInvalidation(dir.toString())); 354 } 355 356 /** 357 * {@inheritDoc} 358 */ 359 @Override public void register(OnFileChangeListener listener, 360 String dirPath, String... globPatterns) 361 throws IOException 362 { 363 Path dir = Paths.get(dirPath); 364 365 if (!Files.isDirectory(dir)) { 366 throw new IllegalArgumentException(dirPath + " not a directory."); 367 } 368 369 if (!mDirPathToListenersMap.containsKey(dir)) { 370 // May throw 371 WatchKey key = dir.register( 372 mWatchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE 373 ); 374 375 mWatchKeyToDirPathMap.put(key, dir); 376 mDirPathToListenersMap.put(dir, concurrentSet()); 377 } 378 379 getListeners(dir).add(listener); 380 381 Set<PathMatcher> patterns = concurrentSet(); 382 383 for (String globPattern : globPatterns) { 384 patterns.add(matcherForGlobExpression(globPattern)); 385 } 386 387 if (patterns.isEmpty()) { 388 // Match everything if no filter is found 389 patterns.add(matcherForGlobExpression("*")); 390 } 391 392 mListenerToFilePatternsMap.put(listener, patterns); 393 394 logger.trace("Watching files matching {} under '{}' for changes", 395 Arrays.toString(globPatterns), dirPath); 396 } 397 398 /** 399 * {@inheritDoc} 400 */ 401 public void unregister(OnFileChangeListener listener) { 402 Path dir = getDir(listener); 403 404 mDirPathToListenersMap.get(dir).remove(listener); 405 406 // is this step truly needed? 407 if (mDirPathToListenersMap.get(dir).isEmpty()) { 408 mDirPathToListenersMap.remove(dir); 409 } 410 411 mListenerToFilePatternsMap.remove(listener); 412 413 WatchKey key = getWatchKey(dir); 414 if (key != null) { 415 mWatchKeyToDirPathMap.remove(key); 416 key.cancel(); 417 } 418 logger.trace("listener unregistered"); 419 } 420 421 /** 422 * {@inheritDoc} 423 */ 424 @Override public void unregisterAll() { 425 // can't simply clear the key->dir map; need to cancel 426 mWatchKeyToDirPathMap.keySet().forEach(WatchKey::cancel); 427 428 mWatchKeyToDirPathMap.clear(); 429 mDirPathToListenersMap.clear(); 430 mListenerToFilePatternsMap.clear(); 431 } 432 433 /** 434 * Start this {@code SimpleDirectoryWatchService} instance by spawning a 435 * new thread. 436 * 437 * @see #stop() 438 */ 439 @Override public void start() { 440 if (mIsRunning.compareAndSet(false, true)) { 441 String name = DirectoryWatchService.class.getSimpleName(); 442 Thread runnerThread = new Thread(this, name); 443 runnerThread.start(); 444 } 445 } 446 447 /** 448 * Stop this {@code SimpleDirectoryWatchService} thread. 449 * 450 * <p>The killing happens lazily, giving the running thread an opportunity 451 * to finish the work at hand.</p> 452 * 453 * @see #start() 454 */ 455 @Override public void stop() { 456 // Kill thread lazily 457 mIsRunning.set(false); 458 } 459 460 /** 461 * {@inheritDoc} 462 */ 463 @Override public boolean isRunning() { 464 return mIsRunning.get(); 465 } 466 467 /** 468 * {@inheritDoc} 469 */ 470 @Override public void run() { 471 logger.info("Starting file watcher service."); 472 473 while (mIsRunning.get()) { 474 WatchKey key; 475 try { 476 key = mWatchService.take(); 477 } catch (InterruptedException e) { 478 logger.trace("{} service interrupted.", 479 DirectoryWatchService.class.getSimpleName()); 480 break; 481 } 482 483 if (null == getDirPath(key)) { 484 logger.error("Watch key not recognized."); 485 continue; 486 } 487 488 notifyListeners(key); 489 490 // Reset key to allow further events for this key to be processed. 491 boolean valid = key.reset(); 492 if (!valid) { 493 // order matters here; if you remove the key first, we can't 494 // work out who the appropriate listeners are. 495 notifyListenersOfInvalidation(key); 496 mWatchKeyToDirPathMap.remove(key); 497 } 498 } 499 500 mIsRunning.set(false); 501 logger.trace("Stopping file watcher service."); 502 } 503}