Redo file watching refactor.

This reverts commit 12fbba1f4ac0a5be8926f1c25dc8ddef1f1db9eb
but also adds a background thread for keeping resources up-to-date.
This keeps the implementation closer to original but stil uses
much less resources.

Both versions of the patch (with and without a thread) pass
all tests at Google.

Change-Id: I7b5e1473a76742f1bc3e6b6eaeecbf31b69ba3a6
diff --git a/dev/core/src/com/google/gwt/dev/resource/impl/ChangedFileAccumulator.java b/dev/core/src/com/google/gwt/dev/resource/impl/ChangedFileAccumulator.java
deleted file mode 100644
index 6341c1f..0000000
--- a/dev/core/src/com/google/gwt/dev/resource/impl/ChangedFileAccumulator.java
+++ /dev/null
@@ -1,239 +0,0 @@
-/*
- * Copyright 2014 Google Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
- * in compliance with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the License
- * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
- * or implied. See the License for the specific language governing permissions and limitations under
- * the License.
- */
-package com.google.gwt.dev.resource.impl;
-
-import com.google.gwt.thirdparty.guava.common.collect.BiMap;
-import com.google.gwt.thirdparty.guava.common.collect.HashBiMap;
-import com.google.gwt.thirdparty.guava.common.collect.HashMultimap;
-import com.google.gwt.thirdparty.guava.common.collect.Lists;
-import com.google.gwt.thirdparty.guava.common.collect.Multimap;
-import com.google.gwt.thirdparty.guava.common.collect.Sets;
-
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.ClosedWatchServiceException;
-import java.nio.file.FileSystems;
-import java.nio.file.FileVisitResult;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.SimpleFileVisitor;
-import java.nio.file.StandardWatchEventKinds;
-import java.nio.file.WatchEvent;
-import java.nio.file.WatchKey;
-import java.nio.file.WatchService;
-import java.nio.file.attribute.BasicFileAttributes;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-import java.util.concurrent.ExecutionException;
-
-/**
- * Listens for and accumulates file changes in a recursive directory tree even when new files are
- * changed in new directories.
- * <p>
- * Since it contains an internal thread it can't be garbage collected until it has been explicitly
- * shutdown.
- * <p>
- * Multiple instances can listen to the same directories at the same time.
- */
-public class ChangedFileAccumulator {
-
-  /**
-   * A runnable that polls for watch events (in a blocking manner). File changes are recorded
-   * and listeners are attached to new directories.
-   */
-  private class WatchEventPoller implements Runnable {
-    @Override
-    public void run() {
-      try {
-        while (true) {
-          WatchKey watchKey;
-          try {
-            watchKey = watchService.take();
-          } catch (InterruptedException e) {
-            // Shutdown has been requested.
-            return;
-          } catch (ClosedWatchServiceException e) {
-            // Shutdown has been requested.
-            return;
-          }
-
-          synchronized (changedFiles) {
-            Path containingDirectory = pathsByWatchKey.get(watchKey);
-
-            for (WatchEvent<?> watchEvent : watchKey.pollEvents()) {
-              WatchEvent.Kind<?> eventKind = watchEvent.kind();
-
-              if (eventKind == StandardWatchEventKinds.OVERFLOW) {
-                setFailed(
-                    new RuntimeException("Changes occurred faster than they could be recorded."));
-                return;
-              }
-
-              if (!(watchEvent.context() instanceof Path)) {
-                continue;
-              }
-
-              Path changedFileName = (Path) watchEvent.context();
-              Path changedPath = containingDirectory.resolve(changedFileName);
-
-              if (eventKind == StandardWatchEventKinds.ENTRY_DELETE) {
-                recursivelyRemovePath(changedPath);
-                continue;
-              }
-
-              // Maybe listen to newly created directories.
-              if (eventKind == StandardWatchEventKinds.ENTRY_CREATE
-                  && Files.isDirectory(changedPath)) {
-                // A new directory and some contained files can be created faster than watches can
-                // be attached to new directories. So it is necessary to look for files in what are
-                // believed to be "newly created" directories and consider those files changed.
-                try {
-                  recursivelyRegisterListeners(
-                      changedPath, true /* considerPreexistingFilesChanged */);
-                } catch (IOException e) {
-                  setFailed(e);
-                  return;
-                }
-              }
-
-              // Record changed files.
-              pathsByParentPath.put(changedPath.getParent(), changedPath);
-              changedFiles.add(changedPath.toFile().getAbsoluteFile());
-            }
-
-            // Ensures that future change events will be seen.
-            if (!watchKey.reset()) {
-              pathsByWatchKey.remove(watchKey);
-            }
-          }
-        }
-      } catch (RuntimeException e) {
-        setFailed(e);
-      }
-    }
-  }
-
-  private final Set<File> changedFiles = Collections.synchronizedSet(Sets.<File> newHashSet());
-  private Thread changePollerThread;
-  private Exception changePollerException;
-  private final BiMap<WatchKey, Path> pathsByWatchKey = HashBiMap.create();
-  private final Multimap<Path, Path> pathsByParentPath = HashMultimap.create();
-  private final WatchService watchService;
-
-  public ChangedFileAccumulator(Path rootDirectory) throws IOException {
-    watchService = FileSystems.getDefault().newWatchService();
-    recursivelyRegisterListeners(rootDirectory, false /* considerPreexistingFilesChanged */);
-    startChangePoller();
-  }
-
-  /**
-   * Returns a sorted copy of the list of files that have changed since the last time changed files
-   * were requested.
-   */
-  public List<File> getAndClearChangedFiles() throws ExecutionException {
-    if (isFailed()) {
-      throw new ExecutionException(changePollerException);
-    }
-
-    synchronized (changedFiles) {
-      List<File> sortedChangedFiles = Lists.newArrayList(changedFiles);
-      changedFiles.clear();
-      Collections.sort(sortedChangedFiles);
-      return sortedChangedFiles;
-    }
-  }
-
-  public boolean isFailed() {
-    return changePollerException != null;
-  }
-
-  public void shutdown() {
-    changePollerThread.interrupt();
-    try {
-      watchService.close();
-    } catch (IOException e) {
-      changePollerException = e;
-      // Am already trying to stop listening, there's nothing to be done about this failure.
-    }
-    changedFiles.clear();
-    pathsByWatchKey.clear();
-  }
-
-  private void recursivelyRegisterListeners(final Path directory,
-      final boolean considerPreexistingFilesChanged) throws IOException {
-    Files.walkFileTree(directory, new SimpleFileVisitor<Path>() {
-      @Override
-      public FileVisitResult preVisitDirectory(Path currentDirectory, BasicFileAttributes attrs)
-          throws IOException {
-        WatchKey watchKey = currentDirectory.register(watchService,
-            StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE,
-            StandardWatchEventKinds.ENTRY_MODIFY);
-
-        // If the recursive directory scan has gone in a loop because of symlinks.
-        if (pathsByWatchKey.containsKey(watchKey)) {
-          return FileVisitResult.SKIP_SUBTREE;
-        }
-
-        if (considerPreexistingFilesChanged) {
-          changedFiles.add(currentDirectory.toFile());
-        }
-        pathsByParentPath.put(currentDirectory.getParent(), currentDirectory);
-        pathsByWatchKey.put(watchKey, currentDirectory);
-        return FileVisitResult.CONTINUE;
-      }
-
-      @Override
-      public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
-        if (attrs.isSymbolicLink()) {
-          recursivelyRegisterListeners(Files.readSymbolicLink(file),
-              considerPreexistingFilesChanged);
-          return FileVisitResult.CONTINUE;
-        }
-        if (considerPreexistingFilesChanged) {
-          changedFiles.add(file.toFile());
-        }
-        pathsByParentPath.put(file.getParent(), file);
-        return FileVisitResult.CONTINUE;
-      }
-    });
-  }
-
-  private void recursivelyRemovePath(final Path path) {
-    changedFiles.add(path.toFile());
-
-    // Stop listening to this path
-    WatchKey removed = pathsByWatchKey.inverse().remove(path);
-    if (removed != null) {
-       removed.cancel();
-    }
-
-    // Remove the path and recurse into sub directories and files.
-    for (Path child : pathsByParentPath.removeAll(path)) {
-      recursivelyRemovePath(child);
-    }
-  }
-
-  private void setFailed(Exception caughtException) {
-    this.changePollerException = caughtException;
-    shutdown();
-  }
-
-  private void startChangePoller() {
-    changePollerThread = new Thread(new WatchEventPoller());
-    // Don't prevent JVM shutdown.
-    changePollerThread.setDaemon(true);
-    changePollerThread.start();
-  }
-}
diff --git a/dev/core/src/com/google/gwt/dev/resource/impl/DirectoryClassPathEntry.java b/dev/core/src/com/google/gwt/dev/resource/impl/DirectoryClassPathEntry.java
index e496f21..041bc76 100644
--- a/dev/core/src/com/google/gwt/dev/resource/impl/DirectoryClassPathEntry.java
+++ b/dev/core/src/com/google/gwt/dev/resource/impl/DirectoryClassPathEntry.java
@@ -16,40 +16,17 @@
 package com.google.gwt.dev.resource.impl;
 
 import com.google.gwt.core.ext.TreeLogger;
-import com.google.gwt.dev.util.Util;
-import com.google.gwt.dev.util.collect.Lists;
-import com.google.gwt.dev.util.msg.Message1String;
-import com.google.gwt.thirdparty.guava.common.annotations.VisibleForTesting;
 import com.google.gwt.thirdparty.guava.common.collect.Maps;
 
 import java.io.File;
 import java.io.IOException;
-import java.util.Collection;
-import java.util.IdentityHashMap;
-import java.util.List;
 import java.util.Map;
-import java.util.concurrent.ExecutionException;
 
 /**
  * A {@link ClassPathEntry} for a directory on the file system.
  */
 public class DirectoryClassPathEntry extends ClassPathEntry {
 
-  private static final String GWT_WATCH_FILE_CHANGES_PROPERTY = "gwt.watchFileChanges";
-  private static final boolean WATCH_FILE_CHANGES =
-      Boolean.parseBoolean(System.getProperty(GWT_WATCH_FILE_CHANGES_PROPERTY, "true"));
-
-  private static class Messages {
-    static final Message1String DESCENDING_INTO_DIR = new Message1String(
-        TreeLogger.SPAM, "Descending into dir: $0");
-
-    static final Message1String INCLUDING_FILE = new Message1String(
-        TreeLogger.DEBUG, "Including file: $0");
-
-    static final Message1String EXCLUDING_FILE = new Message1String(
-        TreeLogger.DEBUG, "Excluding file: $0");
-  }
-
   /**
    * Absolute directory.
    */
@@ -57,20 +34,6 @@
   private final String location;
 
   /**
-   * A cache of previously collected Resource/Resolution pairs for a given PathPrefixSet.
-   */
-  private final
-      Map<PathPrefixSet, Map<AbstractResource, ResourceResolution>>
-      resolutionsByResourcePerPathPrefixSet =
-          new IdentityHashMap<PathPrefixSet, Map<AbstractResource, ResourceResolution>>();
-
-  /**
-   * Whether changed file listening failed either to be started or at some point during execution.
-   * If it has failed then no further attempts to restart should be made.
-   */
-  private boolean listeningFailed;
-
-  /**
    * @param dir an absolute directory
    */
   public DirectoryClassPathEntry(File dir) {
@@ -82,147 +45,23 @@
   @Override
   public Map<AbstractResource, ResourceResolution> findApplicableResources(TreeLogger logger,
       PathPrefixSet pathPrefixSet) {
-    if (!WATCH_FILE_CHANGES) {
-      return scanRecursiveDirectory(logger, pathPrefixSet);
-    }
-
-    ensureListening(logger, pathPrefixSet);
-
-    if (listeningFailed) {
-      return scanRecursiveDirectory(logger, pathPrefixSet);
-    }
-
-    boolean haveCachedResults = resolutionsByResourcePerPathPrefixSet.containsKey(pathPrefixSet);
-    // If this is the first request and thus the cache is empty.
-    if (!haveCachedResults) {
-      // Then perform a full scan and cache the results.
-      return scanRecursiveDirectory(logger, pathPrefixSet);
-    } else {
-      try {
-        return scanChangedFiles(logger, pathPrefixSet);
-      } catch (ExecutionException e) {
-        listeningFailed = true;
-        logger.log(TreeLogger.WARN, "The attempt to retrieve accumulated file changes in " + dir
-            + " failed. Will fall back on full directory scans.");
-        return scanRecursiveDirectory(logger, pathPrefixSet);
-      }
+    try {
+      return ResourceAccumulatorManager.getResources(this, pathPrefixSet);
+    } catch (IOException e) {
+      // Not using logger because there are plenty of places in the compiler that uses
+      // TreeLogger.NULL that causes this problem to be silently ignored.
+      System.err.println("The attempt to retrieve files in " + dir + " failed.");
+      e.printStackTrace();
+      return Maps.newLinkedHashMap();
     }
   }
 
-  private void ensureListening(TreeLogger logger, PathPrefixSet pathPrefixSet) {
-    if (!listeningFailed && !DirectoryPathPrefixChangeManager.isListening(this, pathPrefixSet)) {
-      try {
-        DirectoryPathPrefixChangeManager.ensureListening(this, pathPrefixSet);
-      } catch (IOException e) {
-        listeningFailed = true;
-        logger.log(TreeLogger.WARN, "The attempt to start listening for file changes in " + dir
-            + " failed. Will fall back on full directory scans.");
-      }
-    }
-  }
-
-  @VisibleForTesting
-  File getDirectory() {
+  public File getDirectory() {
     return dir;
   }
 
-  private synchronized Map<AbstractResource, ResourceResolution> scanChangedFiles(
-      TreeLogger logger, PathPrefixSet pathPrefixSet) throws ExecutionException {
-    // Get cached results.
-    Map<AbstractResource, ResourceResolution> resolutionsByResource =
-        resolutionsByResourcePerPathPrefixSet.get(pathPrefixSet);
-
-    // Update cached results.
-    Collection<File> changedFiles =
-        DirectoryPathPrefixChangeManager.getAndClearChangedFiles(this, pathPrefixSet);
-    for (File changedFile : changedFiles) {
-      String changedRelativePath = Util.makeRelativePath(dir, changedFile);
-      FileResource resource = FileResource.of(changedRelativePath, changedFile);
-
-      if (!changedFile.exists()) {
-        if (resolutionsByResource.containsKey(resource)) {
-          resolutionsByResource.remove(resource);
-        }
-        continue;
-      }
-
-      ResourceResolution resourceResolution = pathPrefixSet.includesResource(changedRelativePath);
-      if (resourceResolution != null) {
-        Messages.INCLUDING_FILE.log(logger, changedRelativePath, null);
-        resolutionsByResource.put(resource, resourceResolution);
-      } else {
-        Messages.EXCLUDING_FILE.log(logger, changedRelativePath, null);
-      }
-    }
-    return resolutionsByResource;
-  }
-
-  private synchronized Map<AbstractResource, ResourceResolution> scanRecursiveDirectory(
-      TreeLogger logger, PathPrefixSet pathPrefixSet) {
-    Map<AbstractResource, ResourceResolution> resolutionsByResource = Maps.newHashMap();
-    descendToFindResources(logger, Lists.create(pathPrefixSet), Lists.create(resolutionsByResource),
-        dir, "");
-
-    // Cache results.
-    resolutionsByResourcePerPathPrefixSet.put(pathPrefixSet, resolutionsByResource);
-
-    return resolutionsByResource;
-  }
-
   @Override
   public String getLocation() {
     return location;
   }
-
-  /**
-   * @param logger logs progress
-   * @param pathPrefixSets the sets of path prefixes to determine what resources
-   *          are included
-   * @param results the accumulating sets of resources (each with the
-   *          corresponding pathPrefix) found
-   * @param dir the file or directory to consider
-   * @param dirPath the abstract path name associated with 'parent', which
-   *          explicitly does not include the classpath entry in its path
-   */
-  @VisibleForTesting
-  void descendToFindResources(TreeLogger logger,
-      List<PathPrefixSet> pathPrefixSets,
-      List<Map<AbstractResource, ResourceResolution>> results, File dir, String dirPath) {
-    assert (dir.isDirectory()) : dir + " is not a directory";
-    int len = pathPrefixSets.size();
-
-    // Assert: this directory is included in the path prefix set.
-
-    File[] children = dir.listFiles();
-    for (File child : children) {
-      String childPath = dirPath + child.getName();
-      if (child.isDirectory()) {
-        if (child.isHidden()) {
-          // Don't look inside of intentionally hidden directories. It's just a waste of time,
-          // sometimes lots of time.
-          continue;
-        }
-        String childDirPath = childPath + "/";
-        for (int i = 0; i < len; ++i) {
-          if (pathPrefixSets.get(i).includesDirectory(childDirPath)) {
-            Messages.DESCENDING_INTO_DIR.log(logger, child.getPath(), null);
-            descendToFindResources(logger, pathPrefixSets, results, child,
-                childDirPath);
-            break;
-          }
-        }
-      } else if (child.isFile()) {
-        for (int i = 0; i < len; ++i) {
-          ResourceResolution resourceResolution = null;
-          if ((resourceResolution = pathPrefixSets.get(i).includesResource(childPath)) != null) {
-            Messages.INCLUDING_FILE.log(logger, childPath, null);
-            FileResource r = FileResource.of(childPath, child);
-            results.get(i).put(r, resourceResolution);
-          } else {
-            Messages.EXCLUDING_FILE.log(logger, childPath, null);
-          }
-        }
-      }
-    }
-  }
 }
diff --git a/dev/core/src/com/google/gwt/dev/resource/impl/DirectoryPathPrefixChangeManager.java b/dev/core/src/com/google/gwt/dev/resource/impl/DirectoryPathPrefixChangeManager.java
deleted file mode 100644
index 9bc05f5..0000000
--- a/dev/core/src/com/google/gwt/dev/resource/impl/DirectoryPathPrefixChangeManager.java
+++ /dev/null
@@ -1,162 +0,0 @@
-/*
- * Copyright 2014 Google Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
- * in compliance with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the License
- * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
- * or implied. See the License for the specific language governing permissions and limitations under
- * the License.
- */
-package com.google.gwt.dev.resource.impl;
-
-import com.google.gwt.thirdparty.guava.common.annotations.VisibleForTesting;
-import com.google.gwt.thirdparty.guava.common.collect.Maps;
-
-import java.io.File;
-import java.io.IOException;
-import java.lang.ref.WeakReference;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Objects;
-import java.util.concurrent.ExecutionException;
-
-/**
- * Manages changed file accumulation for DirectoryClassPathEntry + PathPrefixSet pairs.
- * <p>
- * Changed file lists need to be collected and processed separately for each pair since the
- * processing depends on both the directory and the path prefix set.
- * <p>
- * ChangedFileAccumulators consume native resources and so require very strict lifecycle management
- * but ClassPathEntry and PathPrefixSet lifecycle management is very loose. This makes it difficult
- * to release ChangedFileAccumulators at the proper time. This manager class uses weak references to
- * ClassPathEntry and PathPrefixSet instances to lazily discover when ChangedFileAccumulators become
- * eligible for destruction.
- */
-class DirectoryPathPrefixChangeManager {
-
-  /**
-   * A hash key that is a combination of a DirectoryClassPathEntry and PathPrefixSet which also
-   * takes special care not to block the garbage collection of either.
-   */
-  private static class DirectoryAndPathPrefix {
-
-    private final WeakReference<DirectoryClassPathEntry> directoryClassPathEntryReference;
-    private int hashCode;
-    private final WeakReference<PathPrefixSet> pathPrefixSetReference;
-
-    public DirectoryAndPathPrefix(DirectoryClassPathEntry directoryClassPathEntry,
-        PathPrefixSet pathPrefixSet) {
-      this.directoryClassPathEntryReference =
-          new WeakReference<DirectoryClassPathEntry>(directoryClassPathEntry);
-      this.pathPrefixSetReference = new WeakReference<PathPrefixSet>(pathPrefixSet);
-
-      hashCode = Objects.hash(directoryClassPathEntry, pathPrefixSet);
-    }
-
-    @Override
-    public boolean equals(Object object) {
-      if (object instanceof DirectoryAndPathPrefix) {
-        DirectoryAndPathPrefix directoryAndPathPrefix = (DirectoryAndPathPrefix) object;
-        return Objects.equals(directoryClassPathEntryReference.get(),
-            directoryAndPathPrefix.directoryClassPathEntryReference.get()) && Objects.equals(
-            pathPrefixSetReference.get(), directoryAndPathPrefix.pathPrefixSetReference.get());
-      }
-      return false;
-    }
-
-    @Override
-    public int hashCode() {
-      return hashCode;
-    }
-
-    /**
-     * If either the instance has been destroyed then it is no longer possible for a caller to
-     * request the accumulated changed files list for the combination. This means the combination is
-     * old and tracking can be stopped.
-     */
-    public boolean isOld() {
-      return directoryClassPathEntryReference.get() == null || pathPrefixSetReference.get() == null;
-    }
-  }
-
-  private static Map<DirectoryAndPathPrefix, ChangedFileAccumulator>
-      changedFileAccumulatorsByDirectoryAndPathPrefix = Maps.newHashMap();
-
-  /**
-   * Start a FileChangeAccumulator for the given DirectoryClassPathEntry + PathPrefixSet pair.
-   */
-  public static void ensureListening(DirectoryClassPathEntry directoryClassPathEntry,
-      PathPrefixSet pathPrefixSet) throws IOException {
-    clearOldListeners();
-
-    DirectoryAndPathPrefix directoryAndPathPrefix =
-        new DirectoryAndPathPrefix(directoryClassPathEntry, pathPrefixSet);
-
-    synchronized (changedFileAccumulatorsByDirectoryAndPathPrefix) {
-      if (changedFileAccumulatorsByDirectoryAndPathPrefix.containsKey(directoryAndPathPrefix)) {
-        return;
-      }
-
-      changedFileAccumulatorsByDirectoryAndPathPrefix.put(directoryAndPathPrefix,
-          new ChangedFileAccumulator(directoryClassPathEntry.getDirectory().toPath()));
-    }
-  }
-
-  /**
-   * Returns a sorted copy of the list of files that have changed since the last time changed files
-   * were requested.
-   */
-  public static List<File> getAndClearChangedFiles(DirectoryClassPathEntry directoryClassPathEntry,
-      PathPrefixSet pathPrefixSet) throws ExecutionException {
-    ChangedFileAccumulator changedFileAccumulator = changedFileAccumulatorsByDirectoryAndPathPrefix
-        .get(new DirectoryAndPathPrefix(directoryClassPathEntry, pathPrefixSet));
-    assert changedFileAccumulator
-        != null : "Listening must be started before changed files can be requested.";
-    return changedFileAccumulator.getAndClearChangedFiles();
-  }
-
-  @VisibleForTesting
-  static int getActiveListenerCount() {
-    clearOldListeners();
-
-    synchronized (changedFileAccumulatorsByDirectoryAndPathPrefix) {
-      return changedFileAccumulatorsByDirectoryAndPathPrefix.size();
-    }
-  }
-
-  @VisibleForTesting
-  static boolean isListening(DirectoryClassPathEntry directoryClassPathEntry,
-      PathPrefixSet pathPrefixSet) {
-    synchronized (changedFileAccumulatorsByDirectoryAndPathPrefix) {
-      return changedFileAccumulatorsByDirectoryAndPathPrefix.containsKey(
-          new DirectoryAndPathPrefix(directoryClassPathEntry, pathPrefixSet));
-    }
-  }
-
-  /**
-   * Finds any DirectoryClassPathEntry and PathPrefixSet pairs where at least one instance has been
-   * destroyed (and thus can no longer be queried about) and shuts down the associated changed file
-   * accumulation.
-   */
-  private static void clearOldListeners() {
-    synchronized (changedFileAccumulatorsByDirectoryAndPathPrefix) {
-      Iterator<Entry<DirectoryAndPathPrefix, ChangedFileAccumulator>> entriesIterator =
-          changedFileAccumulatorsByDirectoryAndPathPrefix.entrySet().iterator();
-      while (entriesIterator.hasNext()) {
-        Entry<DirectoryAndPathPrefix, ChangedFileAccumulator> entry = entriesIterator.next();
-        DirectoryAndPathPrefix directoryAndPathPrefix = entry.getKey();
-        ChangedFileAccumulator fileChangeAccumulator = entry.getValue();
-        if (directoryAndPathPrefix.isOld()) {
-          fileChangeAccumulator.shutdown();
-          entriesIterator.remove();
-        }
-      }
-    }
-  }
-}
diff --git a/dev/core/src/com/google/gwt/dev/resource/impl/PathPrefixSet.java b/dev/core/src/com/google/gwt/dev/resource/impl/PathPrefixSet.java
index 3a16a16..f412f08 100644
--- a/dev/core/src/com/google/gwt/dev/resource/impl/PathPrefixSet.java
+++ b/dev/core/src/com/google/gwt/dev/resource/impl/PathPrefixSet.java
@@ -324,9 +324,7 @@
 
   private void assertValidAbstractDirectoryPathName(String name) {
     assert (name != null);
-    // assert ("".equals(name) || (!name.startsWith("/") &&
-    // name.endsWith("/")));
-    assert (!name.startsWith("/") && name.endsWith("/"));
+    assert (!name.startsWith("/"));
   }
 
   private void assertValidAbstractResourcePathName(String name) {
diff --git a/dev/core/src/com/google/gwt/dev/resource/impl/ResourceAccumulator.java b/dev/core/src/com/google/gwt/dev/resource/impl/ResourceAccumulator.java
new file mode 100644
index 0000000..9e5dc90
--- /dev/null
+++ b/dev/core/src/com/google/gwt/dev/resource/impl/ResourceAccumulator.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright 2014 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.google.gwt.dev.resource.impl;
+
+import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
+import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE;
+import static java.nio.file.StandardWatchEventKinds.OVERFLOW;
+
+import com.google.gwt.thirdparty.guava.common.collect.ArrayListMultimap;
+import com.google.gwt.thirdparty.guava.common.collect.Maps;
+import com.google.gwt.thirdparty.guava.common.collect.Multimap;
+
+import java.io.IOException;
+import java.lang.ref.WeakReference;
+import java.nio.file.DirectoryStream;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.WatchEvent;
+import java.nio.file.WatchKey;
+import java.nio.file.WatchService;
+import java.util.Map;
+
+/**
+ * Listens for and accumulates resources for a given root and PathPrefixSet.
+ */
+class ResourceAccumulator {
+
+  private static final boolean WATCH_FILE_CHANGES_DEFAULT = Boolean.parseBoolean(
+      System.getProperty("gwt.watchFileChanges", "true"));
+
+  private Map<AbstractResource, ResourceResolution> resolutionsByResource;
+  private Multimap<Path, Path> childPathsByParentPath;
+  private Path rootDirectory;
+  private WeakReference<PathPrefixSet> pathPrefixSetRef;
+  private WatchService watchService;
+  private boolean watchFileChanges = WATCH_FILE_CHANGES_DEFAULT;
+
+  public ResourceAccumulator(Path rootDirectory, PathPrefixSet pathPrefixSet) {
+    this.rootDirectory = rootDirectory;
+    this.pathPrefixSetRef = new WeakReference<PathPrefixSet>(pathPrefixSet);
+  }
+
+  public boolean isWatchServiceActive() {
+    return watchService != null;
+  }
+
+  /**
+   * Make sure the resources associated with this directory and pathPrefixSet are up-to-date.
+   */
+  public void refreshResources() throws IOException {
+    if (isWatchServiceActive()) {
+      refresh();
+    } else {
+      fullRefresh();
+    }
+  }
+
+  public Map<AbstractResource, ResourceResolution> getResources() {
+    return resolutionsByResource;
+  }
+
+  public void shutdown() throws IOException {
+    // watchService field is not cleared so any attempt to use this class after shutdown will fail.
+    stopWatchService();
+  }
+
+  /**
+   * Full refresh clears existing resources and watchers and does a clean refresh.
+   */
+  private void fullRefresh() throws IOException {
+    resolutionsByResource = Maps.newIdentityHashMap();
+    childPathsByParentPath = ArrayListMultimap.create();
+
+    maybeInitializeWatchService();
+
+    onNewDirectory(rootDirectory);
+  }
+
+  private void maybeInitializeWatchService() throws IOException {
+    if (watchFileChanges) {
+      stopWatchService();
+      try {
+        watchService = FileSystems.getDefault().newWatchService();
+      } catch (IOException e) {
+        watchFileChanges = false;
+      }
+    }
+  }
+
+  private void stopWatchService() throws IOException {
+    if (watchService != null) {
+      watchService.close();
+    }
+  }
+
+  private void refresh() throws IOException {
+    while (true) {
+      WatchKey watchKey = watchService.poll();
+      if (watchKey == null) {
+        return;
+      }
+
+      Path parentDir = (Path) watchKey.watchable();
+
+      for (WatchEvent<?> watchEvent : watchKey.pollEvents()) {
+        WatchEvent.Kind<?> eventKind = watchEvent.kind();
+        if (eventKind == OVERFLOW) {
+          fullRefresh();
+          return;
+        }
+
+        Path child = parentDir.resolve((Path) watchEvent.context());
+        if (eventKind == ENTRY_CREATE) {
+          onNewPath(child);
+        } else if (eventKind == ENTRY_DELETE) {
+          onRemovedPath(child);
+        }
+      }
+
+      watchKey.reset();
+    }
+  }
+
+  private void onNewPath(Path path) throws IOException {
+    if (Files.isHidden(path)) {
+      return;
+    }
+
+    if (Files.isRegularFile(path)) {
+      onNewFile(path);
+    } else {
+      onNewDirectory(path);
+    }
+  }
+
+  private void onNewDirectory(Path directory) throws IOException {
+    String relativePath = getRelativePath(directory);
+    if (!relativePath.isEmpty() && !getPathPrefixSet().includesDirectory(relativePath)) {
+      return;
+    }
+
+    if (watchService != null) {
+      // Start watching the directory.
+      directory.register(watchService, ENTRY_CREATE, ENTRY_DELETE);
+    }
+
+    try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
+      for (Path child : stream) {
+        childPathsByParentPath.put(directory, child);
+        onNewPath(child);
+      }
+    }
+  }
+
+  private void onNewFile(Path file) {
+    FileResource resource = toFileResource(file);
+    ResourceResolution resourceResolution = getPathPrefixSet().includesResource(resource.getPath());
+    if (resourceResolution != null) {
+      resolutionsByResource.put(resource, resourceResolution);
+    }
+  }
+
+  private void onRemovedPath(Path path) {
+    resolutionsByResource.remove(toFileResource(path));
+    for (Path child : childPathsByParentPath.get(path)) {
+      onRemovedPath(child);
+    }
+  }
+
+  private FileResource toFileResource(Path path) {
+    String relativePath = getRelativePath(path);
+    return FileResource.of(relativePath, path.toFile());
+  }
+
+  private String getRelativePath(Path directory) {
+    return rootDirectory.relativize(directory).toString();
+  }
+
+  private PathPrefixSet getPathPrefixSet() {
+    PathPrefixSet pathPrefixSet = pathPrefixSetRef.get();
+    // pathPrefixSet can never be null as the life span of this class is bound by it.
+    assert pathPrefixSet != null;
+    return pathPrefixSet;
+  }
+}
diff --git a/dev/core/src/com/google/gwt/dev/resource/impl/ResourceAccumulatorManager.java b/dev/core/src/com/google/gwt/dev/resource/impl/ResourceAccumulatorManager.java
new file mode 100644
index 0000000..b4fa48c
--- /dev/null
+++ b/dev/core/src/com/google/gwt/dev/resource/impl/ResourceAccumulatorManager.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2014 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.google.gwt.dev.resource.impl;
+
+import com.google.gwt.thirdparty.guava.common.annotations.VisibleForTesting;
+import com.google.gwt.thirdparty.guava.common.collect.ImmutableMap;
+import com.google.gwt.thirdparty.guava.common.collect.Maps;
+
+import java.io.IOException;
+import java.lang.ref.WeakReference;
+import java.nio.file.Path;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Objects;
+
+/**
+ * Manages {@link ResourceAccumulator}s for DirectoryClassPathEntry + PathPrefixSet pairs.
+ * <p>
+ * ResourceAccumulators consume native resources and so require very strict lifecycle management but
+ * ClassPathEntry and PathPrefixSet lifecycle management is very loose. This makes it difficult to
+ * release ResourceAccumulator at the proper time. This manager class uses weak references to
+ * ClassPathEntry and PathPrefixSet instances to lazily discover when ResourceAccumulator instances
+ * become eligible for destruction.
+ */
+class ResourceAccumulatorManager {
+
+  /**
+   * A hash key that is a combination of a DirectoryClassPathEntry and PathPrefixSet which also
+   * takes special care not to block the garbage collection of either.
+   */
+  private static class DirectoryAndPathPrefix {
+
+    private final WeakReference<DirectoryClassPathEntry> directoryClassPathEntryRef;
+    private final WeakReference<PathPrefixSet> pathPrefixSetRef;
+    private int hashCode;
+
+    public DirectoryAndPathPrefix(DirectoryClassPathEntry directoryClassPathEntry,
+        PathPrefixSet pathPrefixSet) {
+      this.directoryClassPathEntryRef = new WeakReference<>(directoryClassPathEntry);
+      this.pathPrefixSetRef = new WeakReference<PathPrefixSet>(pathPrefixSet);
+      hashCode = Objects.hash(directoryClassPathEntry, pathPrefixSet);
+    }
+
+    @Override
+    public boolean equals(Object object) {
+      if (object instanceof DirectoryAndPathPrefix) {
+        DirectoryAndPathPrefix other = (DirectoryAndPathPrefix) object;
+        return directoryClassPathEntryRef.get() == other.directoryClassPathEntryRef.get()
+            && pathPrefixSetRef.get() == other.pathPrefixSetRef.get();
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return hashCode;
+    }
+
+    /**
+     * If either the instance has been destroyed then it is no longer possible for a caller to
+     * request the accumulated sources for the combination. This means the combination is
+     * old and tracking can be stopped.
+     */
+    public boolean isOld() {
+      return directoryClassPathEntryRef.get() == null || pathPrefixSetRef.get() == null;
+    }
+  }
+
+  private static Map<DirectoryAndPathPrefix, ResourceAccumulator> resourceAccumulators = Maps
+      .newHashMap();
+
+  static {
+    // Keep the resources fresh
+    new Thread() {
+      @Override
+      public void run() {
+        while (true) {
+          try {
+            refreshResources();
+            Thread.sleep(10);
+          } catch (Exception e) {
+            e.printStackTrace();
+          }
+        }
+      }
+    }.start();
+  }
+
+  public static synchronized Map<AbstractResource, ResourceResolution> getResources(
+      DirectoryClassPathEntry directoryClassPathEntry, PathPrefixSet pathPrefixSet)
+      throws IOException {
+    DirectoryAndPathPrefix directoryAndPathPrefix =
+        new DirectoryAndPathPrefix(directoryClassPathEntry, pathPrefixSet);
+
+    ResourceAccumulator resourceAccumulator = resourceAccumulators.get(directoryAndPathPrefix);
+    if (resourceAccumulator == null) {
+      Path path = directoryClassPathEntry.getDirectory().toPath();
+      resourceAccumulator = new ResourceAccumulator(path, pathPrefixSet);
+      resourceAccumulators.put(directoryAndPathPrefix, resourceAccumulator);
+    }
+    resourceAccumulator.refreshResources();
+    return ImmutableMap.copyOf(resourceAccumulator.getResources());
+  }
+
+  public static synchronized void refreshResources() throws IOException {
+    Iterator<Entry<DirectoryAndPathPrefix, ResourceAccumulator>> entriesIterator =
+        resourceAccumulators.entrySet().iterator();
+    while (entriesIterator.hasNext()) {
+      Entry<DirectoryAndPathPrefix, ResourceAccumulator> entry = entriesIterator.next();
+      DirectoryAndPathPrefix directoryAndPathPrefix = entry.getKey();
+      ResourceAccumulator resourceAccumulator = entry.getValue();
+      if (directoryAndPathPrefix.isOld()) {
+        resourceAccumulator.shutdown();
+        entriesIterator.remove();
+      } else if (resourceAccumulator.isWatchServiceActive()) {
+        resourceAccumulator.refreshResources();
+      }
+    }
+  }
+
+  @VisibleForTesting
+  static int getActiveListenerCount() throws IOException {
+    refreshResources();
+
+    return resourceAccumulators.size();
+  }
+
+  @VisibleForTesting
+  static boolean isListening(DirectoryClassPathEntry directoryClassPathEntry,
+      PathPrefixSet pathPrefixSet) {
+    return resourceAccumulators.containsKey(
+        new DirectoryAndPathPrefix(directoryClassPathEntry, pathPrefixSet));
+  }
+}
diff --git a/dev/core/test/com/google/gwt/dev/resource/impl/ChangedFileAccumulatorTest.java b/dev/core/test/com/google/gwt/dev/resource/impl/ChangedFileAccumulatorTest.java
deleted file mode 100644
index 2c395c6..0000000
--- a/dev/core/test/com/google/gwt/dev/resource/impl/ChangedFileAccumulatorTest.java
+++ /dev/null
@@ -1,286 +0,0 @@
-/*
- * Copyright 2014 Google Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
- * in compliance with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the License
- * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
- * or implied. See the License for the specific language governing permissions and limitations under
- * the License.
- */
-package com.google.gwt.dev.resource.impl;
-
-import com.google.gwt.thirdparty.guava.common.io.Files;
-
-import junit.framework.TestCase;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.List;
-
-/**
- * Tests for ChangedFileAccumulator.
- */
-public class ChangedFileAccumulatorTest extends TestCase {
-
-  public void testAddFile() throws Exception {
-    File rootDirectory = Files.createTempDir();
-    File subDirectory = createDirectoryIn("subdir", rootDirectory);
-
-    ChangedFileAccumulator changedFileAccumulator =
-        new ChangedFileAccumulator(rootDirectory.toPath());
-
-    assertTrue(changedFileAccumulator.getAndClearChangedFiles().isEmpty());
-
-    createFileIn("New.java", subDirectory);
-    waitForFileEvents();
-
-    List<File> modifiedFiles = changedFileAccumulator.getAndClearChangedFiles();
-    assertEquals(1, modifiedFiles.size());
-    assertTrue(modifiedFiles.get(0).getPath().endsWith("New.java"));
-
-    changedFileAccumulator.shutdown();
-  }
-
-  public void testDeleteFile() throws Exception {
-    File rootDirectory = Files.createTempDir();
-    File subDirectory = createDirectoryIn("subdir", rootDirectory);
-    File originalFile = createFileIn("SomeFile.java", subDirectory);
-
-    ChangedFileAccumulator changedFileAccumulator =
-        new ChangedFileAccumulator(rootDirectory.toPath());
-
-    assertTrue(changedFileAccumulator.getAndClearChangedFiles().isEmpty());
-
-    originalFile.delete();
-    waitForFileEvents();
-
-    List<File> modifiedFiles = changedFileAccumulator.getAndClearChangedFiles();
-    assertEquals(1, modifiedFiles.size());
-    assertTrue(modifiedFiles.get(0).getPath().endsWith("SomeFile.java"));
-
-    changedFileAccumulator.shutdown();
-  }
-
-  public void testListensInNewDirectories() throws Exception {
-    File rootDirectory = Files.createTempDir();
-
-    ChangedFileAccumulator changedFileAccumulator =
-        new ChangedFileAccumulator(rootDirectory.toPath());
-
-    assertTrue(changedFileAccumulator.getAndClearChangedFiles().isEmpty());
-
-    // Create a new directory and contained file AFTER the root directory has started being listened
-    // to.
-    File subDirectory = createDirectoryIn("subdir", rootDirectory);
-    createFileIn("New.java", subDirectory);
-    waitForFileEvents();
-
-    List<File> modifiedFiles = changedFileAccumulator.getAndClearChangedFiles();
-    assertEquals(2, modifiedFiles.size());
-    assertTrue(modifiedFiles.get(0).getPath().endsWith("subdir"));
-    assertTrue(modifiedFiles.get(1).getPath().endsWith("New.java"));
-
-    changedFileAccumulator.shutdown();
-  }
-
-  public void testModifyRepeatedly() throws Exception {
-    File rootDirectory = Files.createTempDir();
-    File fooFile = createFileIn("Foo.java", rootDirectory);
-
-    ChangedFileAccumulator changedFileAccumulator =
-        new ChangedFileAccumulator(rootDirectory.toPath());
-
-    assertTrue(changedFileAccumulator.getAndClearChangedFiles().isEmpty());
-
-    for (int i = 0; i < 5; i++) {
-      fooFile.setLastModified(i * 1000);
-      waitForFileEvents();
-      List<File> modifiedFiles = changedFileAccumulator.getAndClearChangedFiles();
-      assertEquals(1, modifiedFiles.size());
-      assertTrue(modifiedFiles.get(0).getPath().endsWith("Foo.java"));
-    }
-
-    changedFileAccumulator.shutdown();
-  }
-
-  public void testMultipleListeners() throws Exception {
-    File rootDirectory = Files.createTempDir();
-    File subDirectory = createDirectoryIn("subdir", rootDirectory);
-
-    ChangedFileAccumulator changedFileAccumulator1 =
-        new ChangedFileAccumulator(rootDirectory.toPath());
-    ChangedFileAccumulator changedFileAccumulator2 =
-        new ChangedFileAccumulator(rootDirectory.toPath());
-
-    assertTrue(changedFileAccumulator1.getAndClearChangedFiles().isEmpty());
-    assertTrue(changedFileAccumulator2.getAndClearChangedFiles().isEmpty());
-
-    createFileIn("New.java", subDirectory);
-    waitForFileEvents();
-
-    List<File> modifiedFiles1 = changedFileAccumulator1.getAndClearChangedFiles();
-    assertEquals(1, modifiedFiles1.size());
-    assertTrue(modifiedFiles1.get(0).getPath().endsWith("New.java"));
-
-    List<File> modifiedFiles2 = changedFileAccumulator2.getAndClearChangedFiles();
-    assertEquals(1, modifiedFiles2.size());
-    assertTrue(modifiedFiles2.get(0).getPath().endsWith("New.java"));
-
-    changedFileAccumulator1.shutdown();
-    changedFileAccumulator2.shutdown();
-  }
-
-  public void testRenameFile() throws Exception {
-    File rootDirectory = Files.createTempDir();
-    File subDirectory = createDirectoryIn("subdir", rootDirectory);
-    File originalFile = createFileIn("OriginalName.java", subDirectory);
-    File renamedFile = new File(subDirectory, "Renamed.java");
-
-    ChangedFileAccumulator changedFileAccumulator =
-        new ChangedFileAccumulator(rootDirectory.toPath());
-
-    assertTrue(changedFileAccumulator.getAndClearChangedFiles().isEmpty());
-
-    originalFile.renameTo(renamedFile);
-    waitForFileEvents();
-
-    List<File> modifiedFiles = changedFileAccumulator.getAndClearChangedFiles();
-    assertEquals(2, modifiedFiles.size());
-    assertTrue(modifiedFiles.get(0).getPath().endsWith("OriginalName.java"));
-    assertTrue(modifiedFiles.get(1).getPath().endsWith("Renamed.java"));
-
-    changedFileAccumulator.shutdown();
-  }
-
-  public void testRenameDirectory() throws Exception {
-    File rootDirectory = Files.createTempDir();
-    File subDirectory = createDirectoryIn("original_dir", rootDirectory);
-    createFileIn("Name1.java", subDirectory);
-    createFileIn("Name2.java", subDirectory);
-    File renamedSubDirectory = new File(rootDirectory, "new_dir");
-
-    ChangedFileAccumulator changedFileAccumulator =
-        new ChangedFileAccumulator(rootDirectory.toPath());
-
-    assertTrue(changedFileAccumulator.getAndClearChangedFiles().isEmpty());
-
-    subDirectory.renameTo(renamedSubDirectory);
-    waitForFileEvents();
-
-    List<File> modifiedFiles = changedFileAccumulator.getAndClearChangedFiles();
-    assertEquals(6, modifiedFiles.size());
-    assertTrue(modifiedFiles.get(0).getPath().endsWith("new_dir"));
-    assertTrue(modifiedFiles.get(1).getPath().endsWith("new_dir/Name1.java"));
-    assertTrue(modifiedFiles.get(2).getPath().endsWith("new_dir/Name2.java"));
-    assertTrue(modifiedFiles.get(3).getPath().endsWith("original_dir"));
-    assertTrue(modifiedFiles.get(4).getPath().endsWith("original_dir/Name1.java"));
-    assertTrue(modifiedFiles.get(5).getPath().endsWith("original_dir/Name2.java"));
-
-    changedFileAccumulator.shutdown();
-  }
-
-  public void testRenameParentDirectory() throws Exception {
-    File rootDirectory = Files.createTempDir();
-    File parentDirectory = createDirectoryIn("original_dir", rootDirectory);
-    File subDirectory = createDirectoryIn("subdir", parentDirectory);
-    createFileIn("Name1.java", subDirectory);
-    createFileIn("Name2.java", subDirectory);
-    File renamedParentDirectory = new File(rootDirectory, "new_dir");
-
-    ChangedFileAccumulator changedFileAccumulator =
-        new ChangedFileAccumulator(rootDirectory.toPath());
-
-    assertTrue(changedFileAccumulator.getAndClearChangedFiles().isEmpty());
-
-    parentDirectory.renameTo(renamedParentDirectory);
-    waitForFileEvents();
-
-    List<File> modifiedFiles = changedFileAccumulator.getAndClearChangedFiles();
-    assertEquals(8, modifiedFiles.size());
-    assertTrue(modifiedFiles.get(0).getPath().endsWith("new_dir"));
-    assertTrue(modifiedFiles.get(1).getPath().endsWith("new_dir/subdir"));
-    assertTrue(modifiedFiles.get(2).getPath().endsWith("new_dir/subdir/Name1.java"));
-    assertTrue(modifiedFiles.get(3).getPath().endsWith("new_dir/subdir/Name2.java"));
-    assertTrue(modifiedFiles.get(4).getPath().endsWith("original_dir"));
-    assertTrue(modifiedFiles.get(5).getPath().endsWith("original_dir/subdir"));
-    assertTrue(modifiedFiles.get(6).getPath().endsWith("original_dir/subdir/Name1.java"));
-    assertTrue(modifiedFiles.get(7).getPath().endsWith("original_dir/subdir/Name2.java"));
-
-    changedFileAccumulator.shutdown();
-  }
-
-  public void testSymlinkInfiniteLoop() throws Exception {
-    File rootDirectory = Files.createTempDir();
-    File subDirectory = Files.createTempDir();
-
-    ChangedFileAccumulator changedFileAccumulator =
-        new ChangedFileAccumulator(rootDirectory.toPath());
-
-    assertTrue(changedFileAccumulator.getAndClearChangedFiles().isEmpty());
-
-    // Symlink in a loop
-    java.nio.file.Files.createSymbolicLink(new File(rootDirectory, "sublink").toPath(),
-        subDirectory.toPath()).toFile();
-    java.nio.file.Files.createSymbolicLink(new File(subDirectory, "sublink").toPath(),
-        rootDirectory.toPath()).toFile();
-    createFileIn("New.java", subDirectory);
-    waitForFileEvents();
-
-    // Will throw an error if ChangedFileAccumulator got stuck in an infinite directory scan loop.
-    List<File> modifiedFiles = changedFileAccumulator.getAndClearChangedFiles();
-    assertEquals(3, modifiedFiles.size());
-    assertTrue(modifiedFiles.get(0).getPath().endsWith("sublink"));
-    assertEquals(modifiedFiles.get(1).getPath(), subDirectory.getPath());
-    assertTrue(modifiedFiles.get(2).getPath().endsWith("New.java"));
-
-    changedFileAccumulator.shutdown();
-  }
-
-  public void testSymlinks() throws Exception {
-    File scratchDirectory = Files.createTempDir();
-    File newFile = createFileIn("New.java", scratchDirectory);
-    File rootDirectory = Files.createTempDir();
-    File subDirectory = Files.createTempDir();
-
-    ChangedFileAccumulator changedFileAccumulator =
-        new ChangedFileAccumulator(rootDirectory.toPath());
-
-    assertTrue(changedFileAccumulator.getAndClearChangedFiles().isEmpty());
-
-    // Symlink in a subdirectory and then symlink in a contained file.
-    java.nio.file.Files.createSymbolicLink(new File(rootDirectory, "sublink").toPath(),
-        subDirectory.toPath()).toFile();
-    java.nio.file.Files.createSymbolicLink(new File(subDirectory, "New.java").toPath(),
-        newFile.toPath()).toFile();
-    waitForFileEvents();
-
-    List<File> modifiedFiles = changedFileAccumulator.getAndClearChangedFiles();
-
-    assertEquals(3, modifiedFiles.size());
-    assertTrue(modifiedFiles.get(0).getPath().endsWith("New.java"));
-    assertTrue(modifiedFiles.get(1).getPath().endsWith("sublink"));
-    assertEquals(modifiedFiles.get(2).getPath(), subDirectory.getPath());
-
-    changedFileAccumulator.shutdown();
-  }
-
-  private static File createDirectoryIn(String fileName, File inDirectory) {
-    File newDirectory = new File(inDirectory, fileName);
-    newDirectory.mkdir();
-    return newDirectory;
-  }
-
-  private static File createFileIn(String fileName, File inDirectory) throws IOException {
-    File newFile = new File(inDirectory, fileName);
-    newFile.createNewFile();
-    return newFile;
-  }
-
-  private void waitForFileEvents() throws InterruptedException {
-    Thread.sleep(100);
-  }
-}
diff --git a/dev/core/test/com/google/gwt/dev/resource/impl/ClassPathEntryTest.java b/dev/core/test/com/google/gwt/dev/resource/impl/ClassPathEntryTest.java
index eef4de3..3843f23 100644
--- a/dev/core/test/com/google/gwt/dev/resource/impl/ClassPathEntryTest.java
+++ b/dev/core/test/com/google/gwt/dev/resource/impl/ClassPathEntryTest.java
@@ -17,7 +17,6 @@
 
 import com.google.gwt.core.ext.TreeLogger;
 import com.google.gwt.thirdparty.guava.common.collect.Lists;
-import com.google.gwt.thirdparty.guava.common.collect.Maps;
 import com.google.gwt.thirdparty.guava.common.io.Files;
 
 import java.io.File;
@@ -25,7 +24,6 @@
 import java.lang.ref.WeakReference;
 import java.net.URISyntaxException;
 import java.util.Collection;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -47,19 +45,14 @@
     File javaFile = new File(nestedHiddenDir, "ShouldNotBeFound.java");
     javaFile.createNewFile();
 
-    // Prepare a place to record findings.
-    List<Map<AbstractResource, ResourceResolution>> foundFiles =
-        Lists.<Map<AbstractResource, ResourceResolution>> newArrayList();
-    foundFiles.add(Maps.<AbstractResource, ResourceResolution> newHashMap());
-
     // Perform a class path directory inspection.
     DirectoryClassPathEntry cpe = new DirectoryClassPathEntry(tempDir);
-    cpe.descendToFindResources(TreeLogger.NULL, Lists.newArrayList(createInclusivePathPrefixSet()),
-        foundFiles, tempDir, "");
+    Map<AbstractResource, ResourceResolution> resources =
+        cpe.findApplicableResources(TreeLogger.NULL, createInclusivePathPrefixSet());
 
     // Verify that even though we're using an ALL filter, we still didn't find any files inside the
     // .svn dir, because we never even enumerate its contents.
-    assertTrue(foundFiles.get(0).isEmpty());
+    assertTrue(resources.isEmpty());
   }
 
   public void testResourceCreated() throws IOException, InterruptedException {
@@ -70,30 +63,60 @@
         createInclusivePathPrefixSet(), createInclusivePathPrefixSet()));
   }
 
-  public void testForResourceListenerLeaks() throws IOException, InterruptedException {
+  public void testForResourceListenerLeaks_pathPrefixSetIsCollected() throws Exception {
     // Create a folder an initially empty folder.
     PathPrefixSet pathPrefixSet = createInclusivePathPrefixSet();
     DirectoryClassPathEntry classPathEntry = new DirectoryClassPathEntry(Files.createTempDir());
 
-    // Show that the WeakDirectoryNotifier is not listening for any updates.
+    // Show that we are not listening.
     awaitFullGc();
-    assertEquals(0, DirectoryPathPrefixChangeManager.getActiveListenerCount());
+    assertEquals(0, ResourceAccumulatorManager.getActiveListenerCount());
 
     // Start listening for updates.
-    DirectoryPathPrefixChangeManager.ensureListening(classPathEntry, pathPrefixSet);
+    ResourceAccumulatorManager.getResources(classPathEntry, pathPrefixSet);
 
-    // Show that the WeakDirectoryNotifier is now listening for updates.
+    // Show that we are now listening for updates.
     awaitFullGc();
-    assertEquals(1, DirectoryPathPrefixChangeManager.getActiveListenerCount());
+    assertEquals(1, ResourceAccumulatorManager.getActiveListenerCount());
 
-    // Dereference the classpath entry and pathprefixset and give the garbage collector an
-    // opportunity to clear any weak references.
+    // Dereference the pathPrefixSet to give garbage collector an opportunity to clear any weak
+    // references.
     pathPrefixSet = null;
+
+    // Show that we are no longer listening for updates.
+    awaitFullGc();
+    assertEquals(0, ResourceAccumulatorManager.getActiveListenerCount());
+
+    // Make sure classPathEntry is not GC'd until this point.
+    assertNotNull(classPathEntry);
+  }
+
+  public void testForResourceListenerLeaks_classPathEntryIsCollected() throws Exception {
+    // Create a folder an initially empty folder.
+    PathPrefixSet pathPrefixSet = createInclusivePathPrefixSet();
+    DirectoryClassPathEntry classPathEntry = new DirectoryClassPathEntry(Files.createTempDir());
+
+    // Show that we are not listening.
+    awaitFullGc();
+    assertEquals(0, ResourceAccumulatorManager.getActiveListenerCount());
+
+    // Start listening for updates.
+    ResourceAccumulatorManager.getResources(classPathEntry, pathPrefixSet);
+
+    // Show that we are now listening for updates.
+    awaitFullGc();
+    assertEquals(1, ResourceAccumulatorManager.getActiveListenerCount());
+
+    // Dereference the classPathEntry to give the garbage collector an opportunity to clear any weak
+    // references.
     classPathEntry = null;
 
-    // Show that the WeakDirectoryNotifier is no longer listening for updates.
+    // Show that we are no longer listening for updates.
     awaitFullGc();
-    assertEquals(0, DirectoryPathPrefixChangeManager.getActiveListenerCount());
+    assertEquals(0, ResourceAccumulatorManager.getActiveListenerCount());
+
+    // Make sure pathPrefixSet is not GC'd until this point.
+    assertNotNull(pathPrefixSet);
   }
 
   public void testResourceCreated(Collection<PathPrefixSet> pathPrefixSets) throws IOException,
diff --git a/dev/core/test/com/google/gwt/dev/resource/impl/ResourceAccumulatorTest.java b/dev/core/test/com/google/gwt/dev/resource/impl/ResourceAccumulatorTest.java
new file mode 100644
index 0000000..545f513
--- /dev/null
+++ b/dev/core/test/com/google/gwt/dev/resource/impl/ResourceAccumulatorTest.java
@@ -0,0 +1,283 @@
+/*
+ * Copyright 2014 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.google.gwt.dev.resource.impl;
+
+import com.google.gwt.thirdparty.guava.common.collect.Lists;
+import com.google.gwt.thirdparty.guava.common.io.Files;
+
+import junit.framework.TestCase;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.FileSystemException;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * Tests for ResourceAccumulator.
+ */
+public class ResourceAccumulatorTest extends TestCase {
+
+  public void testAddFile() throws Exception {
+    File rootDirectory = Files.createTempDir();
+    File subDirectory = createDirectoryIn("subdir", rootDirectory);
+
+    ResourceAccumulator resourceAccumulator =
+        new ResourceAccumulator(rootDirectory.toPath(), createInclusivePathPrefixSet());
+
+    assertTrue(getResources(resourceAccumulator).isEmpty());
+
+    createFileIn("New.java", subDirectory);
+    waitForFileEvents();
+
+    List<AbstractResource> resources = getResources(resourceAccumulator);
+    assertEquals(1, resources.size());
+    assertTrue(resources.get(0).getPath().endsWith("New.java"));
+
+    resourceAccumulator.shutdown();
+  }
+
+  public void testDeleteFile() throws Exception {
+    File rootDirectory = Files.createTempDir();
+    File subDirectory = createDirectoryIn("subdir", rootDirectory);
+    File originalFile = createFileIn("SomeFile.java", subDirectory);
+
+    ResourceAccumulator resourceAccumulator =
+        new ResourceAccumulator(rootDirectory.toPath(), createInclusivePathPrefixSet());
+
+    List<AbstractResource> resources = getResources(resourceAccumulator);
+    assertEquals(1, resources.size());
+    assertTrue(resources.get(0).getPath().endsWith("SomeFile.java"));
+
+    originalFile.delete();
+    waitForFileEvents();
+
+    assertTrue(getResources(resourceAccumulator).isEmpty());
+
+    resourceAccumulator.shutdown();
+  }
+
+  public void testListensInNewDirectories() throws Exception {
+    File rootDirectory = Files.createTempDir();
+
+    ResourceAccumulator resourceAccumulator =
+        new ResourceAccumulator(rootDirectory.toPath(), createInclusivePathPrefixSet());
+
+    assertTrue(getResources(resourceAccumulator).isEmpty());
+
+    // Create a new directory and contained file AFTER the root directory has started being listened
+    // to.
+    File subDirectory = createDirectoryIn("subdir", rootDirectory);
+    createFileIn("New.java", subDirectory);
+    waitForFileEvents();
+
+    List<AbstractResource> resources = getResources(resourceAccumulator);
+    assertEquals(1, resources.size());
+    assertTrue(resources.get(0).getPath().endsWith("New.java"));
+
+    resourceAccumulator.shutdown();
+  }
+
+  public void testMultipleListeners() throws Exception {
+    File rootDirectory = Files.createTempDir();
+    File subDirectory = createDirectoryIn("subdir", rootDirectory);
+
+    ResourceAccumulator resourceAccumulator1 =
+        new ResourceAccumulator(rootDirectory.toPath(), createInclusivePathPrefixSet());
+    ResourceAccumulator resourceAccumulator2 =
+        new ResourceAccumulator(rootDirectory.toPath(), createInclusivePathPrefixSet());
+
+    assertTrue(getResources(resourceAccumulator1).isEmpty());
+    assertTrue(getResources(resourceAccumulator2).isEmpty());
+
+    createFileIn("New.java", subDirectory);
+    waitForFileEvents();
+
+    List<AbstractResource> resources1 = getResources(resourceAccumulator1);
+    assertEquals(1, resources1.size());
+    assertTrue(resources1.get(0).getPath().endsWith("New.java"));
+
+    List<AbstractResource> resources2 = getResources(resourceAccumulator2);
+    assertEquals(1, resources2.size());
+    assertTrue(resources2.get(0).getPath().endsWith("New.java"));
+
+    resourceAccumulator1.shutdown();
+    resourceAccumulator2.shutdown();
+  }
+
+  public void testRenameFile() throws Exception {
+    File rootDirectory = Files.createTempDir();
+    File subDirectory = createDirectoryIn("subdir", rootDirectory);
+    File originalFile = createFileIn("OriginalName.java", subDirectory);
+    File renamedFile = new File(subDirectory, "Renamed.java");
+
+    ResourceAccumulator resourceAccumulator =
+        new ResourceAccumulator(rootDirectory.toPath(), createInclusivePathPrefixSet());
+
+    List<AbstractResource> resources = getResources(resourceAccumulator);
+    assertEquals(1, resources.size());
+    assertTrue(resources.get(0).getPath().endsWith("OriginalName.java"));
+
+    originalFile.renameTo(renamedFile);
+    waitForFileEvents();
+
+    resources = getResources(resourceAccumulator);
+    assertEquals(1, resources.size());
+    assertTrue(resources.get(0).getPath().endsWith("Renamed.java"));
+
+    resourceAccumulator.shutdown();
+  }
+
+  public void testRenameDirectory() throws Exception {
+    File rootDirectory = Files.createTempDir();
+    File subDirectory = createDirectoryIn("original_dir", rootDirectory);
+    createFileIn("Name1.java", subDirectory);
+    createFileIn("Name2.java", subDirectory);
+    File renamedSubDirectory = new File(rootDirectory, "new_dir");
+
+    ResourceAccumulator resourceAccumulator =
+        new ResourceAccumulator(rootDirectory.toPath(), createInclusivePathPrefixSet());
+
+    List<AbstractResource> resources = getResources(resourceAccumulator);
+    assertEquals(2, resources.size());
+    assertTrue(resources.get(0).getPath().endsWith("original_dir/Name1.java"));
+    assertTrue(resources.get(1).getPath().endsWith("original_dir/Name2.java"));
+
+    subDirectory.renameTo(renamedSubDirectory);
+    waitForFileEvents();
+
+    resources = getResources(resourceAccumulator);
+    assertEquals(2, resources.size());
+    assertTrue(resources.get(0).getPath().endsWith("new_dir/Name1.java"));
+    assertTrue(resources.get(1).getPath().endsWith("new_dir/Name2.java"));
+
+    resourceAccumulator.shutdown();
+  }
+
+  public void testRenameParentDirectory() throws Exception {
+    File rootDirectory = Files.createTempDir();
+    File parentDirectory = createDirectoryIn("original_dir", rootDirectory);
+    File subDirectory = createDirectoryIn("subdir", parentDirectory);
+    createFileIn("Name1.java", subDirectory);
+    createFileIn("Name2.java", subDirectory);
+    File renamedParentDirectory = new File(rootDirectory, "new_dir");
+
+    ResourceAccumulator resourceAccumulator =
+        new ResourceAccumulator(rootDirectory.toPath(), createInclusivePathPrefixSet());
+
+    List<AbstractResource> resources = getResources(resourceAccumulator);
+    assertEquals(2, resources.size());
+    assertTrue(resources.get(0).getPath().endsWith("original_dir/subdir/Name1.java"));
+    assertTrue(resources.get(1).getPath().endsWith("original_dir/subdir/Name2.java"));
+
+    parentDirectory.renameTo(renamedParentDirectory);
+    waitForFileEvents();
+
+    resources = getResources(resourceAccumulator);
+    assertEquals(2, resources.size());
+    assertTrue(resources.get(0).getPath().endsWith("new_dir/subdir/Name1.java"));
+    assertTrue(resources.get(1).getPath().endsWith("new_dir/subdir/Name2.java"));
+
+    resourceAccumulator.shutdown();
+  }
+
+  public void testSymlinkInfiniteLoop() throws Exception {
+    File rootDirectory = Files.createTempDir();
+    File subDirectory = Files.createTempDir();
+
+    ResourceAccumulator resourceAccumulator =
+        new ResourceAccumulator(rootDirectory.toPath(), createInclusivePathPrefixSet());
+
+    assertTrue(getResources(resourceAccumulator).isEmpty());
+
+    // Symlink in a loop
+    java.nio.file.Files.createSymbolicLink(new File(rootDirectory, "sublink").toPath(),
+        subDirectory.toPath()).toFile();
+    java.nio.file.Files.createSymbolicLink(new File(subDirectory, "sublink").toPath(),
+        rootDirectory.toPath()).toFile();
+    createFileIn("New.java", subDirectory);
+    waitForFileEvents();
+
+    try {
+      // Should throw an error if resourceAccumulator got stuck in an infinite directory scan loop.
+      getResources(resourceAccumulator);
+      fail();
+    } catch (FileSystemException expected) {
+      // Expected
+    }
+
+    resourceAccumulator.shutdown();
+  }
+
+  public void testSymlinks() throws Exception {
+    File scratchDirectory = Files.createTempDir();
+    File newFile = createFileIn("New.java", scratchDirectory);
+    File rootDirectory = Files.createTempDir();
+    File subDirectory = Files.createTempDir();
+
+    ResourceAccumulator resourceAccumulator =
+        new ResourceAccumulator(rootDirectory.toPath(), createInclusivePathPrefixSet());
+
+    assertTrue(getResources(resourceAccumulator).isEmpty());
+
+    // Symlink in a subdirectory and then symlink in a contained file.
+    java.nio.file.Files.createSymbolicLink(new File(rootDirectory, "sublink").toPath(),
+        subDirectory.toPath()).toFile();
+    java.nio.file.Files.createSymbolicLink(new File(subDirectory, "New.java").toPath(),
+        newFile.toPath()).toFile();
+    waitForFileEvents();
+
+    List<AbstractResource> resources = getResources(resourceAccumulator);
+    assertEquals(1, resources.size());
+    assertTrue(resources.get(0).getPath().endsWith("sublink/New.java"));
+
+    resourceAccumulator.shutdown();
+  }
+
+  private static File createDirectoryIn(String fileName, File inDirectory) {
+    File newDirectory = new File(inDirectory, fileName);
+    newDirectory.mkdir();
+    return newDirectory;
+  }
+
+  private static File createFileIn(String fileName, File inDirectory) throws IOException {
+    File newFile = new File(inDirectory, fileName);
+    newFile.createNewFile();
+    return newFile;
+  }
+
+  private List<AbstractResource> getResources(ResourceAccumulator resourceAccumulator)
+      throws IOException {
+    resourceAccumulator.refreshResources();
+    List<AbstractResource> list = Lists.newArrayList(resourceAccumulator.getResources().keySet());
+    Collections.sort(list, new Comparator<AbstractResource>() {
+      @Override
+      public int compare(AbstractResource a, AbstractResource b) {
+        return a.getLocation().compareTo(b.getLocation());
+      }
+    });
+    return list;
+  }
+
+  private static PathPrefixSet createInclusivePathPrefixSet() {
+    PathPrefixSet pathPrefixes = new PathPrefixSet();
+    pathPrefixes.add(new PathPrefix("", null));
+    return pathPrefixes;
+  }
+
+  private void waitForFileEvents() throws InterruptedException {
+    Thread.sleep(100);
+  }
+}