All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.jgrapes.util.FileSystemWatcher Maven / Gradle / Ivy

/*
 * JGrapes Event Driven Framework
 * Copyright (C) 2023 Michael N. Lipp
 * 
 * This program is free software; you can redistribute it and/or modify it 
 * under the terms of the GNU Affero General Public License as published by 
 * the Free Software Foundation; either version 3 of the License, or 
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful, but 
 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 
 * for more details.
 * 
 * You should have received a copy of the GNU Affero General Public License along 
 * with this program; if not, see .
 */

package org.jgrapes.util;

import java.io.File;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import static java.nio.file.StandardWatchEventKinds.*;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import org.jgrapes.core.Channel;
import org.jgrapes.core.Component;
import org.jgrapes.core.Components;
import org.jgrapes.core.Event;
import org.jgrapes.core.Manager;
import org.jgrapes.core.annotation.Handler;
import org.jgrapes.util.events.FileChanged;
import org.jgrapes.util.events.WatchFile;

/**
 * A component that watches paths in the file system for changes
 * and sends events if such changes occur. 
 */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class FileSystemWatcher extends Component {

    @SuppressWarnings("PMD.FieldNamingConventions")
    protected static final Logger logger
        = Logger.getLogger(FileSystemWatcher.class.getName());

    private final WatcherRegistry watcherRegistry = new WatcherRegistry();
    private final Map subscriptions
        = new ConcurrentHashMap<>();

    /**
     * Creates a new component base with its channel set to
     * itself.
     */
    public FileSystemWatcher() {
        super();
    }

    /**
     * Creates a new component base with its channel set to the given 
     * channel. As a special case {@link Channel#SELF} can be
     * passed to the constructor to make the component use itself
     * as channel. The special value is necessary as you 
     * obviously cannot pass an object to be constructed to its 
     * constructor.
     *
     * @param componentChannel the channel that the component's
     * handlers listen on by default and that 
     * {@link Manager#fire(Event, Channel...)} sends the event to
     */
    public FileSystemWatcher(Channel componentChannel) {
        super(componentChannel);
    }

    /**
     * Register a path to wath. Subsequent {@link FileChanged} 
     * events will be fire on the channel(s) on which the
     * {@link WatchFile} event was fired.
     * 
     * The channel is stored using a weak reference, so no explicit
     * "clear watch" is required.
     *
     * @param event the event
     * @param channel the channel
     * @throws IOException if an I/O exception occurs
     */
    @Handler
    public void onWatchFile(WatchFile event, Channel channel)
            throws IOException {
        final Path path = event.path().toAbsolutePath();
        synchronized (subscriptions) {
            addSubscription(path, channel);
        }
    }

    private Subscription addSubscription(Path watched, Channel channel) {
        var subs = new Subscription(watched, channel);
        try {
            // Using computeIfAbsent causes recursive update
            var watcher = subscriptions.get(watched.getParent());
            if (watcher == null) {
                watcher = watcherRegistry.register(watched.getParent());
            }
            watcher.add(subs);
            if (Files.exists(watched)) {
                Path real = watched.toRealPath();
                if (!real.equals(watched)) {
                    addSubscription(real, channel).linkedFrom(subs);
                }
            }
        } catch (IOException e) {
            logger.log(Level.WARNING, e,
                () -> "Cannot watch: " + e.getMessage());
        }
        return subs;
    }

    private void handleWatchEvent(Path directory) {
        Optional.ofNullable(subscriptions.get(directory))
            .ifPresent(DirectorySubscription::directoryChanged);
    }

    /**
     * The Class WatcherRegistry.
     */
    private final class WatcherRegistry {
        private final Map watchers
            = new ConcurrentHashMap<>();

        private Watcher watcher(Path path) {
            @SuppressWarnings("PMD.CloseResource")
            Watcher watcher = watchers.get(path.getFileSystem());
            if (watcher == null) {
                try {
                    watcher = new Watcher(path.getFileSystem());
                    watchers.put(path.getFileSystem(), watcher);
                } catch (IOException e) {
                    logger.log(Level.WARNING, e,
                        () -> "Cannot get watch service: " + e.getMessage());
                    return null;
                }
            }
            return watcher;
        }

        /**
         * Register.
         *
         * @param toWatch the to watch
         * @return the directory subscription
         */
        public DirectorySubscription register(Path toWatch) {
            Watcher watcher = watcher(toWatch);
            if (watcher == null) {
                return null;
            }
            try {
                var watcherRef = new DirectorySubscription(
                    toWatch.register(watcher.watchService,
                        ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY));
                subscriptions.put(toWatch, watcherRef);
                return watcherRef;
            } catch (IOException e) {
                logger.log(Level.WARNING, e,
                    () -> "Cannot watch: " + e.getMessage());
            }
            return null;
        }

    }

    /**
     * The Class Watcher.
     */
    private final class Watcher {
        private final WatchService watchService;

        private Watcher(FileSystem fileSystem) throws IOException {
            watchService = fileSystem.newWatchService();
            var roots = StreamSupport
                .stream(fileSystem.getRootDirectories().spliterator(), false)
                .map(Path::toString)
                .collect(Collectors.joining(File.pathSeparator));
            (Components.useVirtualThreads() ? Thread.ofVirtual()
                : Thread.ofPlatform()).name(roots + " watcher")
                    .start(() -> {
                        while (true) {
                            try {
                                WatchKey key = watchService.take();
                                // Events have to be consumed
                                key.pollEvents();
                                if (!(key.watchable() instanceof Path)) {
                                    key.reset();
                                    continue;
                                }
                                handleWatchEvent((Path) key.watchable());
                                key.reset();
                            } catch (InterruptedException e) {
                                logger.log(Level.WARNING, e,
                                    () -> "No WatchKey: " + e.getMessage());
                            }
                        }
                    });
        }
    }

    /**
     * The Class DirectorySubscription.
     */
    private class DirectorySubscription {
        private final WatchKey watchKey;
        private final List watched;

        /**
         * Instantiates a new directory watcher.
         *
         * @param watchKey the watch key
         */
        public DirectorySubscription(WatchKey watchKey) {
            this.watchKey = watchKey;
            watched = Collections.synchronizedList(new ArrayList<>());
        }

        /**
         * Adds the subscription.
         *
         * @param subs the subs
         */
        public void add(Subscription subs) {
            watched.add(subs);
        }

        /**
         * Removes the subscription.
         *
         * @param subs the subs
         */
        public void remove(Subscription subs) {
            watched.remove(subs);
            if (watched.isEmpty()) {
                subscriptions.remove(subs.directory());
                watchKey.cancel();
            }

        }

        /**
         * Directory changed.
         */
        public void directoryChanged() {
            // Prevent concurrent modification exception
            List.copyOf(watched).forEach(Subscription::handleChange);
        }
    }

    /**
     * The Class Registree.
     */
    private class Subscription {
        private WeakReference notifyOn;
        private final Path path;
        private Subscription linkedFrom;
        private Subscription linksTo;
        private Instant lastModified;

        /**
         * Instantiates a new subscription.
         *
         * @param path the path
         * @param notifyOn the notify on
         */
        @SuppressWarnings("PMD.UseVarargs")
        public Subscription(Path path, Channel notifyOn) {
            this.notifyOn = new WeakReference<>(notifyOn);
            this.path = path;
            updateLastModified();
        }

        /**
         * Return the directoy of this subscription's path.
         *
         * @return the path
         */
        public Path directory() {
            return path.getParent();
        }

        /**
         * Linked from.
         *
         * @param symLinkSubs the sym link subs
         * @return the subscription
         */
        public Subscription linkedFrom(Subscription symLinkSubs) {
            linkedFrom = symLinkSubs;
            symLinkSubs.linksTo = this;
            notifyOn = null;
            return this;
        }

        /**
         * Removes the subscription.
         */
        public void remove() {
            synchronized (subscriptions) {
                if (linksTo != null) {
                    linksTo.remove();
                }
                var directory = path.getParent();
                var watchInfo = subscriptions.get(directory);
                if (watchInfo == null) {
                    // Shouldn't happen, but...
                    return;
                }
                watchInfo.remove(this);
            }
        }

        private void updateLastModified() {
            try {
                if (!Files.exists(path)) {
                    lastModified = null;
                    return;
                }
                lastModified = Files.getLastModifiedTime(path).toInstant();
            } catch (NoSuchFileException e) {
                // There's a race condition here.
                lastModified = null;
            } catch (IOException e) {
                logger.log(Level.WARNING, e,
                    () -> "Cannot get modified time: " + e.getMessage());
            }
        }

        /**
         * Handle change.
         */
        private void handleChange() {
            Subscription watched = Optional.ofNullable(linkedFrom).orElse(this);

            // Check if channel is still valid
            Channel channel = watched.notifyOn.get();
            if (channel == null) {
                watched.remove();
                return;
            }

            // Evaluate change from the perspective of "watched"
            Instant prevModified = watched.lastModified;
            watched.updateLastModified();
            if (prevModified == null) {
                // Check if created
                if (watched.lastModified != null) {
                    // Yes, created.
                    fire(new FileChanged(watched.path,
                        FileChanged.Kind.CREATED), channel);
                    checkLink(watched, channel);
                }
                return;
            }

            // File has existed (prevModified != null)
            if (watched.lastModified == null) {
                // ... but is now deleted
                if (watched.linksTo != null) {
                    watched.linksTo.remove();
                }
                fire(new FileChanged(watched.path, FileChanged.Kind.DELETED),
                    channel);
                return;
            }

            // Check if modified
            if (!prevModified.equals(watched.lastModified)) {
                fire(new FileChanged(watched.path, FileChanged.Kind.MODIFIED),
                    channel);
                checkLink(watched, channel);
            }
        }

        private void checkLink(Subscription watched, Channel channel) {
            try {
                Path curTarget = watched.path.toRealPath();
                if (!curTarget.equals(watched.path)) {
                    // watched is symbolic link
                    if (watched.linksTo == null) {
                        addSubscription(curTarget, channel).linkedFrom(watched);
                        return;
                    }
                    if (!watched.linksTo.path.equals(curTarget)) {
                        // Link target has changed
                        watched.linksTo.remove();
                        addSubscription(curTarget, channel).linkedFrom(watched);
                    }

                }
            } catch (IOException e) { // NOPMD
                // Race condition, target deleted?
            }
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy