/* Copyright 2016 Software Freedom Conservancy Inc. * * This software is licensed under the GNU LGPL (version 2.1 or later). * See the COPYING file in this distribution. */ // // DirectoryMonitor will monitor an entire directory for changes to all files and directories // within it. It uses FileMonitor to monitor all directories it discovers at initialization // and reports changes to the files and directories just as FileMonitor reports them. Subclasses // can override the notify_* methods to filter or monitor events before the signal is fired, // or can override the signals themselves to be notified afterwards. // // start_discovery() must be called to initiate monitoring. Directories and files will be reported // as they're discovered. Directories will be monitored as they're discovered as well. Discovery // can only be initiated once. // // All signals are virtual and have a corresponding notify_* protected virtual function. // Subclasses can either override the notify or the signal to decide when they want to process // the event. // // DirectoryMonitor also adds a level of intelligence to GLib's monitoring API.Because certain // file/directory events are decomposed by FileMonitor into more atomic events, it's difficult // to know when these "composed" events have occurred. (For example, a file move is reported // as a DELETED event followed by a CREATED event, with no clue that the two are related.) Later // versions added the MOVE event, but we can't rely on those being installed. Also, documentation // suggests it's only available with certain back-ends. // // DirectoryMonitor attempts to solve this by deducing when a set of events actually equals // a composite event. It requires more memory in order to do this (i.e. it stores all files and // their information), but the trade-off is easier file/directory monitoring via familiar // semantics. // // DirectoryMonitor also will synthesize events when normal monitor events don't produce expected // results. For example, if a directory is moved out of DirectoryMonitor's root, it is reported // as a delete event, but none of its children are reported as deleted. Similarly, a directory // rename can be captured as a move, but notifications for all its children are not fired and // are synthesized by DirectoryMonitor. DirectoryMonitor will fire delete and move notifications // for all the directory's children in depth-first order. // // In general, DirectoryMonitor attempts to preserve ordering of events, so that (for example) a // file-altered event doesn't fire before a file-created, and so on. // // Because of these requirements, DirectoryMonitor maintains a FileInfo struct on all directories // and files being monitored. (It maintains the attributes gather during the discovery phase, i.e. // SUPPLIED_ATTRIBUTES.) This information can be retrieved via get_info(), get_file_id(), and // get_etag(). These calls can be made at any time; the information is stored before any signal // is fired. // // Note that DirectoryMonitor currently only supports files and directories. Other file types // (special, symbolic links, shortcuts, and mount points) are not supported. It has been seen // when a temporary file is created for its file type to be reported as "unknown" and when it's // altered/deleted to be reported as a regular file. This means it's possible for a file not to // be reported as discovered or created but to be reported as altered and/or deleted. // // DirectoryMonitor can be configured to not recurse (in which case it only discovers/monitors // the root directory) and to not monitor (in which case only discovery occurs). // public class DirectoryMonitor : Object { public const int DEFAULT_PRIORITY = Priority.LOW; public const FileQueryInfoFlags DIR_INFO_FLAGS = FileQueryInfoFlags.NONE; public const FileQueryInfoFlags FILE_INFO_FLAGS = FileQueryInfoFlags.NOFOLLOW_SYMLINKS; // when using UNKNOWN_FILE_FLAGS, check if the resulting FileInfo's symlink status matches // symlink support for files and directories by calling is_file_symlink_supported(). public const FileQueryInfoFlags UNKNOWN_INFO_FLAGS = FileQueryInfoFlags.NONE; public const bool SUPPORT_DIR_SYMLINKS = true; public const bool SUPPORT_FILE_SYMLINKS = false; public const string SUPPLIED_ATTRIBUTES = Util.FILE_ATTRIBUTES; private const FileMonitorFlags FILE_MONITOR_FLAGS = FileMonitorFlags.SEND_MOVED; private const uint DELETED_EXPIRATION_MSEC = 500; private const int MAX_EXPLORATION_DIRS = 5; private enum FType { FILE, DIRECTORY, UNSUPPORTED } private class QueryInfoQueueElement { private static uint current = 0; public DirectoryMonitor owner; public File file; public File? other_file; public FileMonitorEvent event; public uint position; public ulong time_created_msec; public FileInfo? info = null; public Error? err = null; public bool completed = false; public QueryInfoQueueElement(DirectoryMonitor owner, File file, File? other_file, FileMonitorEvent event) { this.owner = owner; this.file = file; this.other_file = other_file; this.event = event; this.position = current++; this.time_created_msec = now_ms(); } public void on_completed(Object? source, AsyncResult aresult) { File source_file = (File) source; // finish the async operation to get the result try { info = source_file.query_info_async.end(aresult); } catch (Error err) { this.err = err; } // mark as completed completed = true; // notify owner this job is finished, to process the queue owner.process_query_queue(this); } } // The FileInfoMap solves several related problems while maintaining FileInfo's in memory // so they're available to users of DirectoryMonitor as well as DirectoryMonitor itself, // which uses them to detect certain conditions. FileInfoMap uses a File ID to maintain // only unique references to each File (and thus can be used to detect symlinked files). private class FileInfoMap { private Gee.HashMap map = new Gee.HashMap(file_hash, file_equal); private Gee.HashMap id_map = new Gee.HashMap(null, null, file_equal); public FileInfoMap() { } protected bool normalize_file(File file, FileInfo? info, out File normalized, out string id) { // if no info is supplied, see if file straight-up corresponds .. if not, we're out of // luck FileInfo? local_info = info; if (local_info == null) { local_info = map.get(file); if (local_info == null) { normalized = null; id = null; return false; } } string? file_id = get_file_info_id(local_info); if (file_id == null) { normalized = null; id = null; return false; } File? known_file = id_map.get(file_id); id = (string) file_id; normalized = (known_file != null) ? known_file : file; return true; } public bool update(File file, FileInfo info) { // if the file out-and-out exists, remove it now if (map.has_key(file)) { bool removed = map.unset(file); assert(removed); } // if the id exists, remove it too string? existing_id = get_file_info_id(info); if (existing_id != null && id_map.has_key(existing_id)) { bool removed = id_map.unset(existing_id); assert(removed); } string id; File normalized; if (!normalize_file(file, info, out normalized, out id)) return false; map.set(normalized, info); id_map.set(id, normalized); return true; } public bool remove(File file, FileInfo? info) { string id; File normalized; if (!normalize_file(file, info, out normalized, out id)) return false; map.unset(normalized); id_map.unset(id); return true; } // This calls the virtual function remove() for all files, so overriding it is sufficient // (but not necessarily most efficient) public void remove_all(Gee.Collection files) { foreach (File file in files) remove(file, null); } public bool contains(File file, FileInfo? info) { string id; File normalized; if (!normalize_file(file, info, out normalized, out id)) return false; return id_map.has_key(id); } public string? get_id(File file, FileInfo? info) { // if FileInfo is valid, easy pickings if (info != null) return get_file_info_id(info); string id; File normalized; if (!normalize_file(file, null, out normalized, out id)) return null; return id; } public Gee.Collection get_all() { return map.keys; } public FileInfo? get_info(File file) { // if file is known as-is, use that FileInfo? info = map.get(file); if (info != null) return info; string id; File normalized; if (!normalize_file(file, null, out normalized, out id)) return null; return map.get(normalized); } public FileInfo? query_info(File file, Cancellable? cancellable) { FileInfo? info = get_info(file); if (info != null) return info; // This *only* retrieves the file ID, which is then used to obtain the in-memory file // information. try { info = file.query_info(FileAttribute.ID_FILE, UNKNOWN_INFO_FLAGS, cancellable); } catch (Error err) { warning("Unable to query file ID of %s: %s", file.get_path(), err.message); return null; } if (!is_file_symlink_supported(info)) return null; string? id = info.get_attribute_string(FileAttribute.ID_FILE); if (id == null) return null; File? normalized = id_map.get(id); if (normalized == null) return null; return map.get(file); } public File? find_match(FileInfo match) { string? match_id = get_file_info_id(match); if (match_id == null) return null; // get all the interesting matchable items from the supplied FileInfo int64 match_size = match.get_size(); TimeVal match_time = match.get_modification_time(); foreach (File file in map.keys) { FileInfo info = map.get(file); // file id match is instant match if (get_file_info_id(info) == match_id) return file; // if file size *and* modification time match, stop if (match_size != info.get_size()) continue; TimeVal time = info.get_modification_time(); if (time.tv_sec != match_time.tv_sec) continue; return file; } return null; } public void remove_descendents(File root, FileInfoMap descendents) { Gee.ArrayList pruned = null; foreach (File file in map.keys) { File? parent = file.get_parent(); while (parent != null) { if (parent.equal(root)) { if (pruned == null) pruned = new Gee.ArrayList(); pruned.add(file); descendents.update(file, map.get(file)); break; } parent = parent.get_parent(); } } if (pruned != null) remove_all(pruned); } // This returns only the immediate descendents of the root sorted by type. Returns the // total number of children located. public int get_children(File root, Gee.Collection files, Gee.Collection dirs) { int count = 0; foreach (File file in map.keys) { File? parent = file.get_parent(); if (parent == null || !parent.equal(root)) continue; FType ftype = get_ftype(map.get(file)); switch (ftype) { case FType.FILE: files.add(file); count++; break; case FType.DIRECTORY: dirs.add(file); count++; break; default: assert(ftype == FType.UNSUPPORTED); break; } } return count; } } private File root; private bool recurse; private bool monitoring; private Gee.HashMap monitors = new Gee.HashMap(); private Gee.Queue query_info_queue = new Gee.LinkedList< QueryInfoQueueElement>(); private FileInfoMap files = new FileInfoMap(); private FileInfoMap parent_moved = new FileInfoMap(); private Cancellable cancellable = new Cancellable(); private int outstanding_exploration_dirs = 0; private bool started = false; private bool has_discovery_started = false; private uint delete_timer_id = 0; // This signal will be fired *after* directory-moved has been fired. public virtual signal void root_moved(File old_root, File new_root, FileInfo new_root_info) { } // If the root is deleted, then the DirectoryMonitor is essentially dead; it has no monitor // to wait for the root to be re-created, and everything beneath the root is obviously blown // away as well. // // This signal will be fired *after* directory-deleted has been fired. public virtual signal void root_deleted(File root) { } public virtual signal void discovery_started() { } public virtual signal void file_discovered(File file, FileInfo info) { } public virtual signal void directory_discovered(File file, FileInfo info) { } // reason is a user-visible string. May be called more than once during discovery. // Discovery always completes with discovery-completed. public virtual signal void discovery_failed(string reason) { } public virtual signal void discovery_completed() { has_discovery_started = false; mdbg("discovery completed"); } public virtual signal void file_created(File file, FileInfo info) { } public virtual signal void file_moved(File old_file, File new_file, FileInfo new_file_info) { } // FileInfo is not updated for each file-altered signal. public virtual signal void file_altered(File file) { } // This is called when the monitor detects that alteration (attributes or otherwise) to the // file has completed. public virtual signal void file_alteration_completed(File file, FileInfo info) { } public virtual signal void file_attributes_altered(File file) { } public virtual signal void file_deleted(File file) { } // This implies that the directory is now being monitored. public virtual signal void directory_created(File dir, FileInfo info) { } // This implies that the old directory is no longer being monitored and the new one is. public virtual signal void directory_moved(File old_dir, File new_dir, FileInfo new_dir_info) { } // FileInfo is not updated for each directory-altered signal. public virtual signal void directory_altered(File dir) { } // This is called when the monitor detects that alteration (attributes or otherwise) to the // directory has completed. public virtual signal void directory_alteration_completed(File dir, FileInfo info) { } public virtual signal void directory_attributes_altered(File dir) { } // This implies that the directory is now no longer be monitored (unsurprisingly). public virtual signal void directory_deleted(File dir) { } public virtual signal void closed() { } public DirectoryMonitor(File root, bool recurse, bool monitoring) { this.root = root; this.recurse = recurse; this.monitoring = monitoring; } protected static void mdbg(string msg) { #if TRACE_MONITORING debug("%s", msg); #endif } public bool is_recursive() { return recurse; } public bool is_monitoring() { return monitoring; } protected virtual void notify_root_deleted(File root) { assert(this.root.equal(root)); mdbg("root deleted"); root_deleted(root); } private void internal_notify_root_moved(File old_root, File new_root, FileInfo new_root_info) { bool removed = files.remove(old_root, null); assert(removed); bool updated = files.update(new_root, new_root_info); assert(updated); root = new_root; notify_root_moved(old_root, new_root, new_root_info); } protected virtual void notify_root_moved(File old_root, File new_root, FileInfo new_root_info) { assert(this.root.equal(old_root)); mdbg("root moved: %s -> %s".printf(old_root.get_path(), new_root.get_path())); root_moved(old_root, new_root, new_root_info); } protected virtual void notify_discovery_started() { mdbg("discovery started"); discovery_started(); } protected virtual void internal_notify_file_discovered(File file, FileInfo info) { if (!files.update(file, info)) { debug("DirectoryMonitor.internal_notify_file_discovered: %s discovered but not added to file map", file.get_path()); return; } notify_file_discovered(file, info); } protected virtual void notify_file_discovered(File file, FileInfo info) { mdbg("file discovered: %s".printf(file.get_path())); file_discovered(file, info); } protected virtual void internal_notify_directory_discovered(File dir, FileInfo info) { bool updated = files.update(dir, info); assert(updated); notify_directory_discovered(dir, info); } protected virtual void notify_directory_discovered(File dir, FileInfo info) { mdbg("directory discovered: %s".printf(dir.get_path())); directory_discovered(dir, info); } protected virtual void notify_discovery_failed(string reason) { warning("discovery failed: %s", reason); discovery_failed(reason); } protected virtual void notify_discovery_completed() { discovery_completed(); } private void internal_notify_file_created(File file, FileInfo info) { File old_file; FileInfo old_file_info; if (is_file_create_move(file, info, out old_file, out old_file_info)) { internal_notify_file_moved(old_file, file, info); } else { bool updated = files.update(file, info); assert(updated); notify_file_created(file, info); } } protected virtual void notify_file_created(File file, FileInfo info) { mdbg("file created: %s".printf(file.get_path())); file_created(file, info); } private void internal_notify_file_moved(File old_file, File new_file, FileInfo new_file_info) { // don't assert because it's possible this call was generated via a deleted-created // sequence, in which case the old_file won't be in files files.remove(old_file, null); bool updated = files.update(new_file, new_file_info); assert(updated); notify_file_moved(old_file, new_file, new_file_info); } protected virtual void notify_file_moved(File old_file, File new_file, FileInfo new_file_info) { mdbg("file moved: %s -> %s".printf(old_file.get_path(), new_file.get_path())); file_moved(old_file, new_file, new_file_info); } protected virtual void notify_file_altered(File file) { mdbg("file altered: %s".printf(file.get_path())); file_altered(file); } private void internal_notify_file_alteration_completed(File file, FileInfo info) { bool updated = files.update(file, info); assert(updated); notify_file_alteration_completed(file, info); } protected virtual void notify_file_alteration_completed(File file, FileInfo info) { mdbg("file alteration completed: %s".printf(file.get_path())); file_alteration_completed(file, info); } protected virtual void notify_file_attributes_altered(File file) { mdbg("file attributes altered: %s".printf(file.get_path())); file_attributes_altered(file); } private void internal_notify_file_deleted(File file) { bool removed = files.remove(file, null); assert(removed); notify_file_deleted(file); } protected virtual void notify_file_deleted(File file) { mdbg("file deleted: %s".printf(file.get_path())); file_deleted(file); } private void internal_notify_directory_created(File dir, FileInfo info) { File old_dir; FileInfo old_dir_info; if (is_file_create_move(dir, info, out old_dir, out old_dir_info)) { // A directory move, like a file move, is actually a directory-deleted followed // by a directory-created. Unlike a file move, what follows directory-created // is a file/directory-created for each file and directory inside the folder // (although the matching deletes are never fired). We want to issue moves for // all those files as well and suppress the create calls. files.remove_descendents(old_dir, parent_moved); internal_notify_directory_moved(old_dir, old_dir_info, dir, info); } else { bool updated = files.update(dir, info); assert(updated); notify_directory_created(dir, info); } } protected virtual void notify_directory_created(File dir, FileInfo info) { mdbg("directory created: %s".printf(dir.get_path())); directory_created(dir, info); } private void internal_notify_directory_moved(File old_dir, FileInfo old_dir_info, File new_dir, FileInfo new_dir_info) { async_internal_notify_directory_moved.begin(old_dir, old_dir_info, new_dir, new_dir_info); } private async void async_internal_notify_directory_moved(File old_dir, FileInfo old_dir_info, File new_dir, FileInfo new_dir_info) { Gee.ArrayList file_children = new Gee.ArrayList(file_equal); Gee.ArrayList dir_children = new Gee.ArrayList(file_equal); int count = files.get_children(old_dir, file_children, dir_children); if (count > 0) { // descend into directories and let them notify their children on the way up // (if files.get_info() returns null, that indicates the directory/file was already // deleted in recurse) foreach (File dir_child in dir_children) { FileInfo? dir_info = files.get_info(dir_child); if (dir_info == null) { warning("Unable to retrieve directory-moved info for %s", dir_child.get_path()); continue; } yield async_internal_notify_directory_moved(dir_child, dir_info, new_dir.get_child(dir_child.get_basename()), dir_info); } // then move the children foreach (File file_child in file_children) { FileInfo? file_info = files.get_info(file_child); if (file_info == null) { warning("Unable to retrieve directory-moved info for %s", file_child.get_path()); continue; } internal_notify_file_moved(file_child, new_dir.get_child(file_child.get_basename()), file_info); Idle.add(async_internal_notify_directory_moved.callback, DEFAULT_PRIORITY); yield; } } // Don't assert here because it's possible this call was made due to a deleted-created // sequence, in which case the directory has already been removed from files files.remove(old_dir, null); bool updated = files.update(new_dir, new_dir_info); assert(updated); // remove the old monitor and add the new one remove_monitor(old_dir, old_dir_info); add_monitor(new_dir, new_dir_info); notify_directory_moved(old_dir, new_dir, new_dir_info); } protected virtual void notify_directory_moved(File old_dir, File new_dir, FileInfo new_dir_info) { mdbg("directory moved: %s -> %s".printf(old_dir.get_path(), new_dir.get_path())); directory_moved(old_dir, new_dir, new_dir_info); if (old_dir.equal(root)) internal_notify_root_moved(old_dir, new_dir, new_dir_info); } protected virtual void notify_directory_altered(File dir) { mdbg("directory altered: %s".printf(dir.get_path())); directory_altered(dir); } private void internal_notify_directory_alteration_completed(File dir, FileInfo info) { bool updated = files.update(dir, info); assert(updated); notify_directory_alteration_completed(dir, info); } protected virtual void notify_directory_alteration_completed(File dir, FileInfo info) { mdbg("directory alteration completed: %s".printf(dir.get_path())); directory_alteration_completed(dir, info); } protected virtual void notify_directory_attributes_altered(File dir) { mdbg("directory attributes altered: %s".printf(dir.get_path())); directory_attributes_altered(dir); } private void internal_notify_directory_deleted(File dir) { FileInfo? info = files.get_info(dir); assert(info != null); // stop monitoring this directory remove_monitor(dir, info); async_notify_directory_deleted.begin(dir, false); } private async void async_notify_directory_deleted(File dir, bool already_removed) { // Note that in this function no assertion checking is done ... there are many // reasons a deleted file may not be known to the internal bookkeeping; if // the file is gone and we didn't know about it, then it's no problem. // because a directory can be deleted without its children being deleted first (probably // means it has been moved to a location outside of the monitored root), need to // synthesize notifications for all its children Gee.ArrayList file_children = new Gee.ArrayList(file_equal); Gee.ArrayList dir_children = new Gee.ArrayList(file_equal); int count = files.get_children(dir, file_children, dir_children); if (count > 0) { // don't use internal_* variants as they deal with "real" and not synthesized // notifications. also note that files.get_info() can return null because items are // being deleted on the way back up the tree ... when files.get_info() returns null, // it means the file/directory was already deleted in a recursed method, and so no // assertions on them // descend first into directories, deleting files and directories on the way up foreach (File dir_child in dir_children) yield async_notify_directory_deleted(dir_child, false); // now notify deletions on all immediate children files ... don't notify directory // deletion because that's handled right before exiting this method foreach (File file_child in file_children) { files.remove(file_child, null); notify_file_deleted(file_child); Idle.add(async_notify_directory_deleted.callback, DEFAULT_PRIORITY); yield; } } if (!already_removed) files.remove(dir, null); notify_directory_deleted(dir); } protected virtual void notify_directory_deleted(File dir) { mdbg("directory deleted: %s".printf(dir.get_path())); directory_deleted(dir); if (dir.equal(root)) notify_root_deleted(dir); } protected virtual void notify_closed() { mdbg("monitoring of %s closed".printf(root.get_path())); closed(); } public File get_root() { return root; } public bool is_in_root(File file) { return file.has_prefix(root); } public bool has_started() { return started; } public void start_discovery() { assert(!started); has_discovery_started = true; started = true; notify_discovery_started(); // start exploring the directory, adding monitors as the directories are discovered outstanding_exploration_dirs = 1; explore_async.begin(root, null, true); } // This should be called when a DirectoryMonitor needs to be destroyed or released. This // will halt background exploration and close all resources. public virtual void close() { // cancel any outstanding async I/O cancellable.cancel(); // cancel all monitors foreach (FileMonitor monitor in monitors.values) cancel_monitor(monitor); monitors.clear(); notify_closed(); } private static FType get_ftype(FileInfo info) { FileType file_type = info.get_file_type(); switch (file_type) { case FileType.REGULAR: return FType.FILE; case FileType.DIRECTORY: return FType.DIRECTORY; default: mdbg("query_ftype: Unknown file type %s".printf(file_type.to_string())); return FType.UNSUPPORTED; } } private async void explore_async(File dir, FileInfo? dir_info, bool in_discovery) { if (files.contains(dir, dir_info)) { warning("Directory loop detected at %s, not exploring", dir.get_path()); explore_directory_completed(in_discovery); return; } // if FileInfo wasn't supplied by caller, fetch it now FileInfo? local_dir_info = dir_info; if (local_dir_info == null) { try { local_dir_info = yield dir.query_info_async(SUPPLIED_ATTRIBUTES, DIR_INFO_FLAGS, DEFAULT_PRIORITY, cancellable); } catch (Error err) { warning("Unable to retrieve info on %s: %s", dir.get_path(), err.message); explore_directory_completed(in_discovery); return; } } if (local_dir_info.get_is_hidden()) { warning("Ignoring hidden directory %s", dir.get_path()); explore_directory_completed(in_discovery); return; } // File ID is required for directory monitoring. No ID, no ride! // TODO: Replace the warning with notify_discovery_failed() and provide a user-visible // string. if (get_file_info_id(local_dir_info) == null) { warning("Unable to retrieve file ID on %s: skipping", dir.get_path()); explore_directory_completed(in_discovery); return; } // verify this is a directory if (local_dir_info.get_file_type() != FileType.DIRECTORY) { notify_discovery_failed(_("Unable to monitor %s: Not a directory (%s)").printf( dir.get_path(), local_dir_info.get_file_type().to_string())); explore_directory_completed(in_discovery); return; } // collect all directories and files in the directory, to consolidate reporting them as // well as traversing the subdirectories -- but to avoid a lot of unnecessary resource // allocations (think empty directories, or leaf nodes with only files), only allocate // the maps when necessary Gee.HashMap dir_map = null; Gee.HashMap file_map = null; try { FileEnumerator enumerator = yield dir.enumerate_children_async(SUPPLIED_ATTRIBUTES, UNKNOWN_INFO_FLAGS, DEFAULT_PRIORITY, cancellable); for (;;) { List? infos = yield enumerator.next_files_async(10, DEFAULT_PRIORITY, cancellable); if (infos == null) break; foreach (FileInfo info in infos) { // we don't deal with hidden files or directories if (info.get_is_hidden()) { warning("Skipping hidden file/directory %s", dir.get_child(info.get_name()).get_path()); continue; } // check for symlink support if (!is_file_symlink_supported(info)) continue; switch (info.get_file_type()) { case FileType.REGULAR: if (file_map == null) file_map = new Gee.HashMap(file_hash, file_equal); file_map.set(dir.get_child(info.get_name()), info); break; case FileType.DIRECTORY: if (dir_map == null) dir_map = new Gee.HashMap(file_hash, file_equal); dir_map.set(dir.get_child(info.get_name()), info); break; default: // ignored break; } } } } catch (Error err2) { warning("Aborted directory traversal of %s: %s", dir.get_path(), err2.message); explore_directory_completed(in_discovery); return; } // report the local (caller-supplied) directory as discovered *before* reporting its files if (in_discovery) internal_notify_directory_discovered(dir, local_dir_info); else internal_notify_directory_created(dir, local_dir_info); // now with everything snarfed up and the directory reported as discovered, begin // monitoring the directory add_monitor(dir, local_dir_info); // report files in local directory if (file_map != null) yield notify_directory_files(file_map, in_discovery); // post all the subdirectory traversals, allowing them to report themselves as discovered if (recurse && dir_map != null) { foreach (File subdir in dir_map.keys) { if (++outstanding_exploration_dirs > MAX_EXPLORATION_DIRS) yield explore_async(subdir, dir_map.get(subdir), in_discovery); else explore_async.begin(subdir, dir_map.get(subdir), in_discovery); } } explore_directory_completed(in_discovery); } private async void notify_directory_files(Gee.Map map, bool in_discovery) { Gee.MapIterator iter = map.map_iterator(); while (iter.next()) { if (in_discovery) internal_notify_file_discovered(iter.get_key(), iter.get_value()); else internal_notify_file_created(iter.get_key(), iter.get_value()); Idle.add(notify_directory_files.callback, DEFAULT_PRIORITY); yield; } } // called whenever exploration of a directory is completed, to know when to signal that // discovery has ended private void explore_directory_completed(bool in_discovery) { assert(outstanding_exploration_dirs > 0); outstanding_exploration_dirs--; if (in_discovery && outstanding_exploration_dirs == 0) notify_discovery_completed(); } // Only submit directories ... file monitoring is wasteful when a single directory monitor can // do all the work. Returns true if monitor added, false if already monitored (or not // monitoring, or unable to monitor due to error). private bool add_monitor(File dir, FileInfo info) { if (!monitoring) return false; string? id = files.get_id(dir, info); if (id == null) return false; // if one already exists, nop if (monitors.has_key(id)) return false; FileMonitor monitor = null; try { monitor = dir.monitor_directory(FILE_MONITOR_FLAGS, null); } catch (Error err) { warning("Unable to monitor %s: %s", dir.get_path(), err.message); return false; } monitors.set(id, monitor); monitor.changed.connect(on_monitor_notification); mdbg("Added monitor for %s".printf(dir.get_path())); return true; } // Returns true if the directory is removed (i.e. was being monitored). private bool remove_monitor(File dir, FileInfo info) { if (!monitoring) return false; string? id = files.get_id(dir, info); if (id == null) return false; FileMonitor? monitor = monitors.get(id); if (monitor == null) return false; bool removed = monitors.unset(id); assert(removed); cancel_monitor(monitor); mdbg("Removed monitor for %s".printf(dir.get_path())); return true; } private void cancel_monitor(FileMonitor monitor) { monitor.changed.disconnect(on_monitor_notification); monitor.cancel(); } private void on_monitor_notification(File file, File? other_file, FileMonitorEvent event) { mdbg("NOTIFY %s: file=%s other_file=%s".printf(event.to_string(), file.get_path(), other_file != null ? other_file.get_path() : "(none)")); // The problem: Having basic file information about each file is valuable (and necessary // in certain situations), but it is a blocking operation, no matter how "quick" it // may seem. Async I/O is perfect to handle this, but it can complete out of order, and // it's highly desirous to report events in the same order they're received. FileInfo // queries are queued up then and processed in order as they're completed. // Every event needs to be queued, but not all events generates query I/O QueryInfoQueueElement query_info = new QueryInfoQueueElement(this, file, other_file, event); query_info_queue.offer(query_info); switch (event) { case FileMonitorEvent.CREATED: case FileMonitorEvent.CHANGES_DONE_HINT: file.query_info_async.begin(SUPPLIED_ATTRIBUTES, UNKNOWN_INFO_FLAGS, DEFAULT_PRIORITY, cancellable, query_info.on_completed); break; case FileMonitorEvent.DELETED: // don't complete it yet, it might be followed by a CREATED event indicating a // move ... instead, let it sit on the queue and allow the timer (or a coming // CREATED event) complete it if (delete_timer_id == 0) delete_timer_id = Timeout.add(DELETED_EXPIRATION_MSEC / 2, check_for_expired_delete_events); break; case FileMonitorEvent.MOVED: // unlike the others, other_file is the destination of the move, and therefore the // one we need to get info on if (other_file != null) { other_file.query_info_async.begin(SUPPLIED_ATTRIBUTES, UNKNOWN_INFO_FLAGS, DEFAULT_PRIORITY, cancellable, query_info.on_completed); } else { warning("Unable to process MOVED event: no other_file"); query_info_queue.remove(query_info); } break; default: // artificially complete it query_info.completed = true; process_query_queue(query_info); break; } } private void process_query_queue(QueryInfoQueueElement? query_info) { // if the completed element was a CREATE event, attempt to match it to a DELETE (which // then converts into a MOVED) and remove the CREATE event if (query_info != null && query_info.info != null && query_info.event == FileMonitorEvent.CREATED) { // if there's no match in the files table for this created file, then it can't be // matched to a previously deleted file File? match = files.find_match(query_info.info); if (match != null) { bool matched = false; foreach (QueryInfoQueueElement enqueued in query_info_queue) { if (enqueued.event != FileMonitorEvent.DELETED || enqueued.completed || !match.equal(enqueued.file)) { continue; } mdbg("Matching CREATED %s to DELETED %s for MOVED".printf(query_info.file.get_path(), enqueued.file.get_path())); enqueued.event = FileMonitorEvent.MOVED; enqueued.other_file = query_info.file; enqueued.info = query_info.info; enqueued.completed = true; matched = true; break; } if (matched) query_info_queue.remove(query_info); } } // peel off completed events from the queue in order for (;;) { // check if empty or waiting for completion on the next event QueryInfoQueueElement? next = query_info_queue.peek(); if (next == null || !next.completed) break; // remove QueryInfoQueueElement? n = query_info_queue.poll(); assert(next == n); mdbg("Completed info query %u for %s on %s".printf(next.position, next.event.to_string(), next.file.get_path())); if (next.err != null) { mdbg("Unable to retrieve file information for %s, dropping %s: %s".printf( next.file.get_path(), next.event.to_string(), next.err.message)); continue; } // Directory monitoring requires file ID. No ID, no ride! if (next.info != null && get_file_info_id(next.info) == null) { mdbg("Unable to retrieve file ID for %s, dropping %s".printf(next.file.get_path(), next.event.to_string())); continue; } // watch for symlink support if (next.info != null && !is_file_symlink_supported(next.info)) { mdbg("No symlink support for %s, dropping %s".printf(next.file.get_path(), next.event.to_string())); continue; } on_monitor_notification_ready(next.file, next.other_file, next.info, next.event); } } private void on_monitor_notification_ready(File file, File? other_file, FileInfo? info, FileMonitorEvent event) { mdbg("READY %s: file=%s other_file=%s".printf(event.to_string(), file.get_path(), other_file != null ? other_file.get_path() : "(null)")); // Nasty, nasty switches-in-a-switch construct, but this demuxes the possibilities into // easily digestible upcalls and signals switch (event) { case FileMonitorEvent.CREATED: assert(info != null); FType ftype = get_ftype(info); switch (ftype) { case FType.FILE: internal_notify_file_created(file, info); break; case FType.DIRECTORY: // other files may have been created under this new directory before we have // a chance to register a monitor, so scan it now looking for new additions // (this call will notify of creation and monitor this new directory once // it's been scanned) outstanding_exploration_dirs++; explore_async.begin(file, info, false); break; default: assert(ftype == FType.UNSUPPORTED); break; } break; case FileMonitorEvent.CHANGED: // don't query info for each change, but only when done hint comes down the pipe assert(info == null); FileInfo local_info = get_file_info(file); if (local_info == null) { mdbg("Changed event for unknown file %s".printf(file.get_path())); break; } FType ftype = get_ftype(local_info); switch (ftype) { case FType.FILE: notify_file_altered(file); break; case FType.DIRECTORY: notify_directory_altered(file); break; default: assert(ftype == FType.UNSUPPORTED); break; } break; case FileMonitorEvent.CHANGES_DONE_HINT: assert(info != null); FType ftype = get_ftype(info); switch (ftype) { case FType.FILE: internal_notify_file_alteration_completed(file, info); break; case FType.DIRECTORY: internal_notify_directory_alteration_completed(file, info); break; default: assert(ftype == FType.UNSUPPORTED); break; } break; case FileMonitorEvent.MOVED: assert(info != null); assert(other_file != null); // in the moved case, file info is for other file (the destination in the move // operation) FType ftype = get_ftype(info); switch (ftype) { case FType.FILE: internal_notify_file_moved(file, other_file, info); break; case FType.DIRECTORY: // get the old FileInfo (contained in files) FileInfo? old_dir_info = files.get_info(file); if (old_dir_info == null) { warning("Directory moved event for unknown file %s", file.get_path()); break; } internal_notify_directory_moved(file, old_dir_info, other_file, info); break; default: assert(ftype == FType.UNSUPPORTED); break; } break; case FileMonitorEvent.DELETED: assert(info == null); FileInfo local_info = get_file_info(file); if (local_info == null) { warning("Deleted event for unknown file %s", file.get_path()); break; } FType ftype = get_ftype(local_info); switch (ftype) { case FType.FILE: internal_notify_file_deleted(file); break; case FType.DIRECTORY: internal_notify_directory_deleted(file); break; default: assert(ftype == FType.UNSUPPORTED); break; } break; case FileMonitorEvent.ATTRIBUTE_CHANGED: // doesn't fetch attributes until CHANGES_DONE_HINT comes down the pipe assert(info == null); FileInfo local_info = get_file_info(file); if (local_info == null) { warning("Attribute changed event for unknown file %s", file.get_path()); break; } FType ftype = get_ftype(local_info); switch (ftype) { case FType.FILE: notify_file_attributes_altered(file); break; case FType.DIRECTORY: notify_directory_attributes_altered(file); break; default: assert(ftype == FType.UNSUPPORTED); break; } break; case FileMonitorEvent.PRE_UNMOUNT: case FileMonitorEvent.UNMOUNTED: // not currently handling these events break; default: warning("Unknown directory monitor event %s", event.to_string()); break; } } // Returns true if a move occurred. Internal state is modified to recognize the // situation (i.e. the move should be reported). private bool is_file_create_move(File file, FileInfo info, out File old_file, out FileInfo old_file_info) { // look for created file whose parent was actually moved File? match = parent_moved.find_match(info); if (match != null) { old_file = match; old_file_info = parent_moved.get_info(match); parent_moved.remove(match, info); return true; } old_file = null; old_file_info = null; return false; } private bool check_for_expired_delete_events() { ulong expiration = now_ms() - DELETED_EXPIRATION_MSEC; bool any_deleted = false; bool any_expired = false; foreach (QueryInfoQueueElement element in query_info_queue) { if (element.event != FileMonitorEvent.DELETED) continue; any_deleted = true; if (element.time_created_msec > expiration) continue; // synthesize the completion element.completed = true; any_expired = true; } if (any_expired) process_query_queue(null); if (!any_deleted) delete_timer_id = 0; return any_deleted; } // This method does its best to return FileInfo for the file. It performs no I/O. public FileInfo? get_file_info(File file) { return files.get_info(file); } // This method returns all files and directories that the DirectoryMonitor knows of. This // call is only useful when runtime monitoring is enabled. It performs no I/O. public Gee.Collection get_files() { return files.get_all(); } // This method will attempt to find the in-memory FileInfo for the file, but if it cannot // be found it will query the file for it's ID and obtain in-memory file information from // there. public FileInfo? query_file_info(File file) { return files.query_info(file, cancellable); } // This checks if the FileInfo is for a symlinked file/directory and if symlinks for the file // type are supported by DirectoryMonitor. Note that this requires the FileInfo have support // for the "standard::is-symlink" and "standard::type" file attributes, which SUPPLIED_ATTRIBUTES // provides. // // Returns true if the file is not a symlink or if symlinks are supported for the file type, // false otherwise. If an unsupported file type, returns false. public static bool is_file_symlink_supported(FileInfo info) { if (!info.get_is_symlink()) return true; FType ftype = get_ftype(info); switch (ftype) { case FType.DIRECTORY: return SUPPORT_DIR_SYMLINKS; case FType.FILE: return SUPPORT_FILE_SYMLINKS; default: assert(ftype == FType.UNSUPPORTED); return false; } } }