view src/fswatcher.cpp @ 721:744933aba33a

Ensure black pen (otherwise this becomes invisible with macOS in dark mode)
author Chris Cannam
date Thu, 13 Dec 2018 11:07:12 +0000
parents 646e48a0d3a5
children f1dc72b940d5
line wrap: on
line source
/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */

/*
    EasyMercurial

    Based on hgExplorer by Jari Korhonen
    Copyright (c) 2010 Jari Korhonen
    Copyright (c) 2013 Chris Cannam
    Copyright (c) 2013 Queen Mary, University of London
    
    This program is free software; you can redistribute it and/or
    modify it under the terms of the GNU General Public License as
    published by the Free Software Foundation; either version 2 of the
    License, or (at your option) any later version.  See the file
    COPYING included with this distribution for more information.
*/

#include <QMutexLocker>
#include <QDir>

#ifdef Q_OS_MAC
// Must include this before debug.h
#include <CoreServices/CoreServices.h>
#endif

#include "fswatcher.h"
#include "debug.h"

#include <deque>

//#define DEBUG_FSWATCHER 1

/*
 * Watching the filesystem is trickier than it seems at first glance.
 *
 * We ideally should watch every directory, and every file that is
 * tracked by Hg. If a new file is created in a directory, then we
 * need to respond in order to show it as a potential candidate to be
 * added.
 *
 * Complicating matters though is that Hg itself might modify the
 * filesystem. This can happen even in "read-only" operations: for
 * example, hg stat creates files called hg-checklink and hg-checkexec
 * to test properties of the filesystem. So we need to know to ignore
 * those files; unfortunately, when watching a directory (which is how
 * we find out about the creation of new files) we are notified only
 * that the directory has changed -- we aren't told what changed.
 *
 * This means that, when a directory changes, we need to rescan the
 * directory to learn whether the set of files in it _excluding_ files
 * matching our ignore patterns differs from the previous scan, and
 * ignore the change if it doesn't.
 *
 */

/*
 * 20120312 -- Another complication. The documentation for
 * QFileSystemWatcher says:
 *
 *     On Mac OS X 10.4 [...] an open file descriptor is required for
 *     each monitored file. [...] This means that addPath() and
 *     addPaths() will fail if your process tries to add more than 256
 *     files or directories to the file system monitor [...] Mac OS X
 *     10.5 and up use a different backend and do not suffer from this
 *     issue.
 * 
 * Unfortunately, the last sentence above is not true:
 * http://qt.gitorious.org/qt/qt/commit/6d1baf9979346d6f15da81a535becb4046278962
 * ("Removing the usage of FSEvents-based backend for now as it has a
 * few bugs...").  It can't be restored without hacking the Qt source,
 * which we don't want to do in this context. The commit log doesn't
 * make clear how serious the bugs were -- an example is given but it
 * doesn't indicate whether it's an edge case or a common case and
 * whether the result was a crash or failure to notify.
 *
 * This means the Qt class uses kqueue instead on OS/X, but that
 * doesn't really work for us -- it can only monitor 256 files (or
 * whatever the fd ulimit is set to, but that's the default) and it
 * doesn't notify if a file within a directory is modified unless the
 * metadata changes. The main limitation of FSEvents is that it only
 * notifies with directory granularity, but that might be OK for us so
 * long as notifications are actually provoked by file changes as
 * well. (In OS/X 10.7 there appear to be file-level notifications
 * too, but that doesn't help us.)
 *
 * One other problem with FSEvents is that the API only exists on OS/X
 * 10.5 or newer -- on older versions we would have no option but to
 * use kqueue via QFileSystemWatcher. But we can't ship a binary
 * linked with the FSEvents API to run on 10.4 without some fiddling,
 * and I'm not really keen to do that either.  That may be our cue to
 * drop 10.4 support for EasyMercurial.
 */

FsWatcher::FsWatcher() :
    m_lastToken(0),
    m_lastCounter(0)
{
#ifdef Q_OS_MAC
    m_stream = 0; // create when we have a path
#else
    connect(&m_watcher, SIGNAL(directoryChanged(QString)),
	    this, SLOT(fsDirectoryChanged(QString)));
    connect(&m_watcher, SIGNAL(fileChanged(QString)),
	    this, SLOT(fsFileChanged(QString)));
#endif
}

FsWatcher::~FsWatcher()
{
}

void
FsWatcher::setWorkDirPath(QString path)
{
    QMutexLocker locker(&m_mutex);
    if (m_workDirPath == path) return;
    clearWatchedPaths();
    m_workDirPath = path;
    addWorkDirectory(path);
    debugPrint();
}

void
FsWatcher::clearWatchedPaths()
{
#ifdef Q_OS_MAC
    FSEventStreamRef stream = (FSEventStreamRef)m_stream;
    if (stream) {
        FSEventStreamStop(stream);
        FSEventStreamInvalidate(stream);
        FSEventStreamRelease(stream);
    }
    m_stream = 0;
#else
    // annoyingly, removePaths prints a warning if given an empty list
    if (!m_watcher.directories().empty()) {
        m_watcher.removePaths(m_watcher.directories());
    }
    if (!m_watcher.files().empty()) {
        m_watcher.removePaths(m_watcher.files());
    }
#endif
}

#ifdef Q_OS_MAC
static void
fsEventsCallback(ConstFSEventStreamRef /* streamRef */,
                 void *clientCallBackInfo,
                 size_t numEvents,
                 void *paths,
                 const FSEventStreamEventFlags /* eventFlags */[],
                 const FSEventStreamEventId /*eventIDs */[])
{
    FsWatcher *watcher = reinterpret_cast<FsWatcher *>(clientCallBackInfo);
    const char *const *cpaths = reinterpret_cast<const char *const *>(paths);
    for (size_t i = 0; i < numEvents; ++i) {
#ifdef DEBUG_FSWATCHER
        std::cerr << "path " << i << " = " << cpaths[i] << std::endl;
#endif
        watcher->fsDirectoryChanged(QString::fromLocal8Bit(cpaths[i]));
    }
}
#endif

void
FsWatcher::addWorkDirectory(QString path)
{
#ifdef Q_OS_MAC

    CFStringRef cfPath = CFStringCreateWithCharacters
        (0, reinterpret_cast<const UniChar *>(path.unicode()),
         path.length());

    CFArrayRef cfPaths = CFArrayCreate(0, (const void **)&cfPath, 1, 0);

    FSEventStreamContext ctx = { 0, 0, 0, 0, 0 };
    ctx.info = this;

    FSEventStreamRef stream =
        FSEventStreamCreate(kCFAllocatorDefault,
                            &fsEventsCallback,
                            &ctx,
                            cfPaths,
                            kFSEventStreamEventIdSinceNow,
                            1.0, // latency, seconds
                            kFSEventStreamCreateFlagNone);

    m_stream = stream;
    
    FSEventStreamScheduleWithRunLoop(stream,
                                     CFRunLoopGetCurrent(),
                                     kCFRunLoopDefaultMode);

    if (!FSEventStreamStart(stream)) {
        std::cerr << "ERROR: FsWatcher::addWorkDirectory: Failed to start FSEvent stream" << std::endl;
    }
#else
    // QFileSystemWatcher will refuse to add a file or directory to
    // its watch list that it is already watching -- fine -- but it
    // prints a warning when this happens, which we wouldn't want.  So
    // we'll check for duplicates ourselves.
    QSet<QString> alreadyWatched = 
	QSet<QString>::fromList(m_watcher.directories());
    
    std::deque<QString> pending;
    pending.push_back(path);

    while (!pending.empty()) {

        QString path = pending.front();
        pending.pop_front();
        if (!alreadyWatched.contains(path)) {
            m_watcher.addPath(path);
            m_dirContents[path] = scanDirectory(path);
        }

        QDir d(path);
        if (d.exists()) {
            d.setFilter(QDir::Dirs | QDir::NoDotAndDotDot |
                        QDir::Readable | QDir::NoSymLinks);
            foreach (QString entry, d.entryList()) {
                if (entry.startsWith('.')) continue;
                QString entryPath = d.absoluteFilePath(entry);
                pending.push_back(entryPath);
            }
        }
    }
#endif
}

void
FsWatcher::setTrackedFilePaths(QStringList paths)
{
#ifdef Q_OS_MAC

    // FSEvents will notify when any file in the directory changes,
    // but we need to be able to check whether the file change was
    // meaningful to us if it didn't result in any files being added
    // or removed -- and we have to do that by examining timestamps on
    // the files we care about
    foreach (QString p, paths) {
        m_trackedFileUpdates[p] = QDateTime::currentDateTime();
    }

#else

    QMutexLocker locker(&m_mutex);

    QSet<QString> alreadyWatched = 
	QSet<QString>::fromList(m_watcher.files());

    foreach (QString path, paths) {
        path = m_workDirPath + QDir::separator() + path;
        if (!alreadyWatched.contains(path)) {
            m_watcher.addPath(path);
        } else {
            alreadyWatched.remove(path);
        }
    }

    // Remove the remaining paths, those that were being watched
    // before but that are not in the list we were given
    foreach (QString path, alreadyWatched) {
        m_watcher.removePath(path);
    }

    debugPrint();

#endif
}

void
FsWatcher::setIgnoredFilePrefixes(QStringList prefixes)
{
    QMutexLocker locker(&m_mutex);
    m_ignoredPrefixes = prefixes;
}

void
FsWatcher::setIgnoredFileSuffixes(QStringList suffixes)
{
    QMutexLocker locker(&m_mutex);
    m_ignoredSuffixes = suffixes;
}

int
FsWatcher::getNewToken()
{
    QMutexLocker locker(&m_mutex);
    int token = ++m_lastToken;
    m_tokenMap[token] = m_lastCounter;
    return token;
}

QSet<QString>
FsWatcher::getChangedPaths(int token)
{
    QMutexLocker locker(&m_mutex);
    size_t lastUpdatedAt = m_tokenMap[token];
    QSet<QString> changed;
    for (QHash<QString, size_t>::const_iterator i = m_changes.begin();
         i != m_changes.end(); ++i) {
        if (i.value() > lastUpdatedAt) {
             changed.insert(i.key());
        }
    }
    m_tokenMap[token] = m_lastCounter;
    return changed;
}

void
FsWatcher::fsDirectoryChanged(QString path)
{
    bool haveChanges = false;

    {
	QMutexLocker locker(&m_mutex);

	if (shouldIgnore(path)) return;

        QSet<QString> files = scanDirectory(path);

        if (files == m_dirContents[path]) {

#ifdef DEBUG_FSWATCHER
            std::cerr << "FsWatcher: Directory " << path << " has changed, but not in a way that we are monitoring -- doing manual check" << std::endl;
#endif

#ifdef Q_OS_MAC
            haveChanges = manuallyCheckTrackedFiles();
#endif

        } else {

#ifdef DEBUG_FSWATCHER
            std::cerr << "FsWatcher: Directory " << path << " has changed" << std::endl;
#endif
            m_dirContents[path] = files;
            size_t counter = ++m_lastCounter;
            m_changes[path] = counter;
            haveChanges = true;
        }
    }

    if (haveChanges) {
        emit changed();
    }
}

void
FsWatcher::fsFileChanged(QString path)
{
    {
        QMutexLocker locker(&m_mutex);

        // We don't check whether the file matches an ignore pattern,
        // because we are only notified for file changes if we are
        // watching the file explicitly, i.e. the file is in the
        // tracked file paths list. So we never want to ignore these

#ifdef DEBUG_FSWATCHER
        std::cerr << "FsWatcher: Tracked file " << path << " has changed" << std::endl;
#endif

        size_t counter = ++m_lastCounter;
        m_changes[path] = counter;
    }

    emit changed();
}

#ifdef Q_OS_MAC
bool
FsWatcher::manuallyCheckTrackedFiles()
{
#ifdef DEBUG_FSWATCHER
    std::cerr << "FsWatcher::manuallyCheckTrackedFiles" << std::endl;
#endif
    bool foundChanges = false;

    for (PathTimeMap::iterator i = m_trackedFileUpdates.begin();
         i != m_trackedFileUpdates.end(); ++i) {

        QString path = i.key();
        QDateTime prevUpdate = i.value();

        QFileInfo fi(m_workDirPath + QDir::separator() + path);
        QDateTime currUpdate = fi.lastModified();

//        std::cerr << "FsWatcher: Tracked file " << path << " previously changed at "
//                  << prevUpdate.toString().toStdString()
//                  << ", currently at " << currUpdate.toString().toStdString() << std::endl;

        if (currUpdate > prevUpdate) {

#ifdef DEBUG_FSWATCHER
            std::cerr << "FsWatcher: Tracked file " << path << " has been changed since last check" << std::endl;
#endif
            i.value() = currUpdate;
            
            size_t counter = ++m_lastCounter;
            m_changes[path] = counter;
            foundChanges = true;
        }
    }
    
    return foundChanges;
}
#endif

bool
FsWatcher::shouldIgnore(QString path)
{
    QFileInfo fi(path);
    QString fn(fi.fileName());
    foreach (QString pfx, m_ignoredPrefixes) {
        if (fn.startsWith(pfx)) {
#ifdef DEBUG_FSWATCHER
            std::cerr << "(ignoring: " << path << ")" << std::endl;
#endif
            return true;
        }
    }
    foreach (QString sfx, m_ignoredSuffixes) {
        if (fn.endsWith(sfx)) {
#ifdef DEBUG_FSWATCHER
            std::cerr << "(ignoring: " << path << ")" << std::endl;
#endif
            return true;
        }
    }
    return false;
}

QSet<QString>
FsWatcher::scanDirectory(QString path)
{
    QSet<QString> files;
    QDir d(path);
    if (d.exists()) {
        d.setFilter(QDir::Files | QDir::NoDotAndDotDot |
                    QDir::Readable | QDir::NoSymLinks);
        foreach (QString entry, d.entryList()) {
            if (entry.startsWith('.')) continue;
            if (shouldIgnore(entry)) continue;
            files.insert(entry);
        }
    }
    return files;
}

void
FsWatcher::debugPrint()
{
#ifdef DEBUG_FSWATCHER
#ifndef Q_OS_MAC
    std::cerr << "FsWatcher: Now watching " << m_watcher.directories().size()
              << " directories and " << m_watcher.files().size()
              << " files" << std::endl;
#endif
#endif
}