blob: 8d00a82d4814e950340a76fe55eb9de4a0d2c892 [file] [log] [blame]
/*
* Copyright 2000-2014 JetBrains s.r.o.
*
* 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.intellij.openapi.vfs.impl.local;
import com.intellij.execution.process.OSProcessHandler;
import com.intellij.execution.process.ProcessAdapter;
import com.intellij.execution.process.ProcessEvent;
import com.intellij.execution.process.ProcessOutputTypes;
import com.intellij.ide.actions.ShowFilePathAction;
import com.intellij.notification.*;
import com.intellij.openapi.application.*;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.util.*;
import com.intellij.openapi.util.io.FileUtil;
import com.intellij.openapi.vfs.LocalFileSystem;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.openapi.vfs.newvfs.ManagingFS;
import com.intellij.util.SmartList;
import com.intellij.util.TimeoutUtil;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.TestOnly;
import javax.swing.event.HyperlinkEvent;
import java.io.BufferedWriter;
import java.io.File;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static com.intellij.util.containers.ContainerUtil.*;
/**
* @author max
*/
public class FileWatcher {
private static final Logger LOG = Logger.getInstance("#com.intellij.openapi.vfs.impl.local.FileWatcher");
public static final NotNullLazyValue<NotificationGroup> NOTIFICATION_GROUP = new NotNullLazyValue<NotificationGroup>() {
@NotNull @Override
protected NotificationGroup compute() {
return new NotificationGroup("File Watcher Messages", NotificationDisplayType.STICKY_BALLOON, true);
}
};
public static class DirtyPaths {
public final List<String> dirtyPaths = newArrayList();
public final List<String> dirtyPathsRecursive = newArrayList();
public final List<String> dirtyDirectories = newArrayList();
private static DirtyPaths EMPTY = new DirtyPaths();
private boolean isEmpty() {
return dirtyPaths.isEmpty() && dirtyPathsRecursive.isEmpty() && dirtyDirectories.isEmpty();
}
}
private static final String PROPERTY_WATCHER_DISABLED = "idea.filewatcher.disabled";
private static final String PROPERTY_WATCHER_EXECUTABLE_PATH = "idea.filewatcher.executable.path";
private static final String ROOTS_COMMAND = "ROOTS";
private static final String EXIT_COMMAND = "EXIT";
private static final int MAX_PROCESS_LAUNCH_ATTEMPT_COUNT = 10;
private final ManagingFS myManagingFS;
private final File myExecutable;
private volatile MyProcessHandler myProcessHandler;
private volatile int myStartAttemptCount = 0;
private volatile boolean myIsShuttingDown = false;
private final AtomicBoolean myFailureShownToTheUser = new AtomicBoolean(false);
private final AtomicInteger mySettingRoots = new AtomicInteger(0);
private volatile List<String> myRecursiveWatchRoots = emptyList();
private volatile List<String> myFlatWatchRoots = emptyList();
private volatile List<String> myManualWatchRoots = emptyList();
private volatile List<Pair<String, String>> myMapping = emptyList();
private final Object myLock = new Object();
private DirtyPaths myDirtyPaths = new DirtyPaths();
private final String[] myLastChangedPaths = new String[2];
private int myLastChangedPathIndex;
FileWatcher(@NotNull ManagingFS managingFS) {
myManagingFS = managingFS;
boolean disabled = Boolean.parseBoolean(System.getProperty(PROPERTY_WATCHER_DISABLED));
myExecutable = getExecutable();
if (disabled) {
LOG.info("Native file watcher is disabled");
}
else if (myExecutable == null) {
LOG.info("Native file watcher is not supported on this platform");
}
else if (!myExecutable.exists()) {
notifyOnFailure(ApplicationBundle.message("watcher.exe.not.found"), null);
}
else if (!myExecutable.canExecute()) {
notifyOnFailure(ApplicationBundle.message("watcher.exe.not.exe", myExecutable), new NotificationListener() {
@Override
public void hyperlinkUpdate(@NotNull Notification notification, @NotNull HyperlinkEvent event) {
ShowFilePathAction.openFile(myExecutable);
}
});
}
else {
try {
startupProcess(false);
LOG.info("Native file watcher is operational.");
}
catch (IOException e) {
LOG.warn(e.getMessage());
notifyOnFailure(ApplicationBundle.message("watcher.failed.to.start"), null);
}
}
}
public void dispose() {
myIsShuttingDown = true;
shutdownProcess();
}
public boolean isOperational() {
return myProcessHandler != null;
}
public boolean isSettingRoots() {
return isOperational() && mySettingRoots.get() > 0;
}
@NotNull
public DirtyPaths getDirtyPaths() {
synchronized (myLock) {
if (!myDirtyPaths.isEmpty()) {
DirtyPaths dirtyPaths = myDirtyPaths;
myDirtyPaths = new DirtyPaths();
myLastChangedPathIndex = 0;
for (int i = 0; i < myLastChangedPaths.length; ++i) myLastChangedPaths[i] = null;
return dirtyPaths;
}
else {
return DirtyPaths.EMPTY;
}
}
}
@NotNull
public List<String> getManualWatchRoots() {
return myManualWatchRoots;
}
public void setWatchRoots(@NotNull List<String> recursive, @NotNull List<String> flat) {
setWatchRoots(recursive, flat, false);
}
public boolean isWatched(@NotNull VirtualFile file) {
return isOperational() && !checkWatchable(file.getPresentableUrl(), true, true).isEmpty();
}
/* internal stuff */
@Nullable
private static File getExecutable() {
String execPath = System.getProperty(PROPERTY_WATCHER_EXECUTABLE_PATH);
if (execPath != null) return new File(execPath);
String execName = getExecutableName(false);
if (execName == null) return null;
return FileUtil.findFirstThatExist(
FileUtil.join(PathManager.getBinPath(), execName),
FileUtil.join(PathManager.getHomePath(), "community", "bin", getExecutableName(true)),
FileUtil.join(PathManager.getBinPath(), getExecutableName(true)));
}
@Nullable
private static String getExecutableName(final boolean withSubDir) {
if (SystemInfo.isWindows) return (withSubDir ? "win" + File.separator : "") + "fsnotifier.exe";
else if (SystemInfo.isMac) return (withSubDir ? "mac" + File.separator : "") + "fsnotifier";
else if (SystemInfo.isLinux) return (withSubDir ? "linux" + File.separator : "") + (SystemInfo.isAMD64 ? "fsnotifier64" : "fsnotifier");
return null;
}
public void notifyOnFailure(final String cause, @Nullable final NotificationListener listener) {
LOG.warn(cause);
if (myFailureShownToTheUser.compareAndSet(false, true)) {
ApplicationManager.getApplication().invokeLater(new Runnable() {
public void run() {
String title = ApplicationBundle.message("watcher.slow.sync");
Notifications.Bus.notify(NOTIFICATION_GROUP.getValue().createNotification(title, cause, NotificationType.WARNING, listener));
}
}, ModalityState.NON_MODAL);
}
}
private void startupProcess(boolean restart) throws IOException {
if (myIsShuttingDown) {
return;
}
if (ShutDownTracker.isShutdownHookRunning()) {
myIsShuttingDown = true;
return;
}
if (myStartAttemptCount++ > MAX_PROCESS_LAUNCH_ATTEMPT_COUNT) {
notifyOnFailure(ApplicationBundle.message("watcher.failed.to.start"), null);
return;
}
if (restart) {
shutdownProcess();
}
LOG.info("Starting file watcher: " + myExecutable);
ProcessBuilder processBuilder = new ProcessBuilder(myExecutable.getAbsolutePath());
Process process = processBuilder.start();
myProcessHandler = new MyProcessHandler(process);
myProcessHandler.addProcessListener(new MyProcessAdapter());
myProcessHandler.startNotify();
if (restart) {
List<String> recursive = myRecursiveWatchRoots;
List<String> flat = myFlatWatchRoots;
if (recursive.size() + flat.size() > 0) {
setWatchRoots(recursive, flat, true);
}
}
}
private void shutdownProcess() {
final OSProcessHandler processHandler = myProcessHandler;
if (processHandler != null) {
if (!processHandler.isProcessTerminated()) {
boolean forceQuite = true;
try {
writeLine(EXIT_COMMAND);
forceQuite = !processHandler.waitFor(500);
if (forceQuite) {
LOG.warn("File watcher is still alive. Doing a force quit.");
}
}
catch (IOException ignore) { }
if (forceQuite) {
processHandler.destroyProcess();
}
}
myProcessHandler = null;
}
}
private synchronized void setWatchRoots(List<String> recursive, List<String> flat, boolean restart) {
if (myProcessHandler == null || myProcessHandler.isProcessTerminated()) return;
if (ApplicationManager.getApplication().isDisposeInProgress()) {
recursive = flat = Collections.emptyList();
}
if (!restart && myRecursiveWatchRoots.equals(recursive) && myFlatWatchRoots.equals(flat)) {
return;
}
mySettingRoots.incrementAndGet();
myMapping = emptyList();
myRecursiveWatchRoots = recursive;
myFlatWatchRoots = flat;
try {
writeLine(ROOTS_COMMAND);
for (String path : recursive) {
writeLine(path);
}
for (String path : flat) {
writeLine("|" + path);
}
writeLine("#");
}
catch (IOException e) {
LOG.warn(e);
}
}
private void writeLine(final String line) throws IOException {
if (LOG.isDebugEnabled()) {
LOG.debug("<< " + line);
}
final MyProcessHandler processHandler = myProcessHandler;
if (processHandler != null) {
processHandler.writeLine(line);
}
}
private static class MyProcessHandler extends OSProcessHandler {
private final BufferedWriter myWriter;
@SuppressWarnings("IOResourceOpenedButNotSafelyClosed")
private MyProcessHandler(@NotNull Process process) {
super(process, null, null); // do not access EncodingManager here
myWriter = new BufferedWriter(new OutputStreamWriter(process.getOutputStream()));
}
private void writeLine(String line) throws IOException {
myWriter.write(line);
myWriter.newLine();
myWriter.flush();
}
@Override
protected boolean useAdaptiveSleepingPolicyWhenReadingOutput() {
return true;
}
}
@NotNull
private Collection<String> checkWatchable(String reportedPath, boolean isExact, boolean fastPath) {
if (reportedPath == null) return Collections.emptyList();
List<String> flatWatchRoots = myFlatWatchRoots;
List<String> recursiveWatchRoots = myRecursiveWatchRoots;
if (flatWatchRoots.isEmpty() && recursiveWatchRoots.isEmpty()) return Collections.emptyList();
List<Pair<String, String>> mapping = myMapping;
Collection <String> affectedPaths = new SmartList<String>(reportedPath);
for (Pair<String, String> map : mapping) {
if (FileUtil.startsWith(reportedPath, map.first)) {
affectedPaths.add(map.second + reportedPath.substring(map.first.length()));
}
else if (FileUtil.startsWith(reportedPath, map.second)) {
affectedPaths.add(map.first + reportedPath.substring(map.second.length()));
}
}
Collection<String> changedPaths = new SmartList<String>();
ext:
for (String path : affectedPaths) {
if (fastPath && !changedPaths.isEmpty()) break;
for (String root : flatWatchRoots) {
if (FileUtil.namesEqual(path, root)) {
changedPaths.add(path);
continue ext;
}
if (isExact) {
String parentPath = new File(path).getParent();
if (parentPath != null && FileUtil.namesEqual(parentPath, root)) {
changedPaths.add(path);
continue ext;
}
}
}
for (String root : recursiveWatchRoots) {
if (FileUtil.startsWith(path, root)) {
changedPaths.add(path);
continue ext;
}
if (!isExact) {
String parentPath = new File(root).getParent();
if (parentPath != null && FileUtil.namesEqual(path, parentPath)) {
changedPaths.add(root);
continue ext;
}
}
}
}
return changedPaths;
}
@SuppressWarnings("SpellCheckingInspection")
private enum WatcherOp {
GIVEUP, RESET, UNWATCHEABLE, REMAP, MESSAGE, CREATE, DELETE, STATS, CHANGE, DIRTY, RECDIRTY
}
private class MyProcessAdapter extends ProcessAdapter {
private WatcherOp myLastOp = null;
private final List<String> myLines = newArrayList();
@Override
public void processTerminated(ProcessEvent event) {
LOG.warn("Watcher terminated with exit code " + event.getExitCode());
myProcessHandler = null;
try {
startupProcess(true);
}
catch (IOException e) {
shutdownProcess();
LOG.warn("Watcher terminated and attempt to restart has failed. Exiting watching thread.", e);
}
}
@Override
public void onTextAvailable(ProcessEvent event, Key outputType) {
if (outputType == ProcessOutputTypes.STDERR) {
LOG.warn(event.getText().trim());
}
if (outputType != ProcessOutputTypes.STDOUT) {
return;
}
final String line = event.getText().trim();
if (LOG.isDebugEnabled()) {
LOG.debug(">> " + line);
}
if (myLastOp == null) {
final WatcherOp watcherOp;
try {
watcherOp = WatcherOp.valueOf(line);
}
catch (IllegalArgumentException e) {
final String message = "Illegal watcher command: " + line;
if (ApplicationManager.getApplication().isUnitTestMode()) LOG.debug(message); else LOG.error(message);
return;
}
if (watcherOp == WatcherOp.GIVEUP) {
notifyOnFailure(ApplicationBundle.message("watcher.gave.up"), null);
myIsShuttingDown = true;
}
else if (watcherOp == WatcherOp.RESET) {
reset();
}
else {
myLastOp = watcherOp;
}
}
else if (myLastOp == WatcherOp.MESSAGE) {
notifyOnFailure(line, NotificationListener.URL_OPENING_LISTENER);
myLastOp = null;
}
else if (myLastOp == WatcherOp.REMAP || myLastOp == WatcherOp.UNWATCHEABLE) {
if ("#".equals(line)) {
if (myLastOp == WatcherOp.REMAP) {
processRemap();
}
else {
mySettingRoots.decrementAndGet();
processUnwatchable();
}
myLines.clear();
myLastOp = null;
}
else {
myLines.add(line);
}
}
else {
String path = line.replace('\0', '\n'); // unescape
processChange(path, myLastOp);
myLastOp = null;
}
}
private void processRemap() {
Set<Pair<String, String>> pairs = newHashSet();
for (int i = 0; i < myLines.size() - 1; i += 2) {
String pathA = preparePathForMapping(myLines.get(i));
String pathB = preparePathForMapping(myLines.get(i + 1));
pairs.add(Pair.create(pathA, pathB));
}
myMapping = newArrayList(pairs);
notifyOnEvent();
}
private String preparePathForMapping(String path) {
String localPath = FileUtil.toSystemDependentName(path);
return localPath.endsWith(File.separator) ? localPath : localPath + File.separator;
}
private void processUnwatchable() {
myManualWatchRoots = Collections.unmodifiableList(newArrayList(myLines));
notifyOnEvent();
}
private void reset() {
VirtualFile[] localRoots = myManagingFS.getLocalRoots();
synchronized (myLock) {
for (VirtualFile root : localRoots) {
myDirtyPaths.dirtyPathsRecursive.add(root.getPresentableUrl());
}
}
notifyOnEvent();
}
private void processChange(String path, WatcherOp op) {
if (SystemInfo.isWindows && op == WatcherOp.RECDIRTY && path.length() == 3 && Character.isLetter(path.charAt(0))) {
VirtualFile root = LocalFileSystem.getInstance().findFileByPath(path);
if (root != null) {
synchronized (myLock) {
myDirtyPaths.dirtyPathsRecursive.add(root.getPresentableUrl());
}
}
notifyOnEvent();
return;
}
if (op == WatcherOp.CHANGE) {
// collapse subsequent change file change notifications that happen once we copy large file,
// this allows reduction of path checks at least 20% for Windows
synchronized (myLock) {
for (int i = 0; i < myLastChangedPaths.length; ++i) {
int last = myLastChangedPathIndex - i - 1;
if (last < 0) last += myLastChangedPaths.length;
String lastChangedPath = myLastChangedPaths[last];
if (lastChangedPath != null && lastChangedPath.equals(path)) {
return;
}
}
myLastChangedPaths[myLastChangedPathIndex++] = path;
if (myLastChangedPathIndex == myLastChangedPaths.length) myLastChangedPathIndex = 0;
}
}
int length = path.length();
if (length > 1 && path.charAt(length - 1) == '/') path = path.substring(0, length - 1);
boolean exactPath = op != WatcherOp.DIRTY && op != WatcherOp.RECDIRTY;
Collection<String> paths = checkWatchable(path, exactPath, false);
if (paths.isEmpty()) {
if (LOG.isDebugEnabled()) {
LOG.debug("Not watchable, filtered: " + path);
}
return;
}
synchronized (myLock) {
switch (op) {
case STATS:
case CHANGE:
myDirtyPaths.dirtyPaths.addAll(paths);
break;
case CREATE:
case DELETE:
for (String p : paths) {
myDirtyPaths.dirtyPaths.add(p);
String parentPath = new File(p).getParent();
if (parentPath != null) {
myDirtyPaths.dirtyPaths.add(parentPath);
}
}
break;
case DIRTY:
myDirtyPaths.dirtyDirectories.addAll(paths);
break;
case RECDIRTY:
myDirtyPaths.dirtyPathsRecursive.addAll(paths);
break;
default:
LOG.error("Unexpected op: " + op);
}
}
notifyOnEvent();
}
}
/* test data and methods */
private volatile Runnable myNotifier = null;
private void notifyOnEvent() {
final Runnable notifier = myNotifier;
if (notifier != null) {
notifier.run();
}
}
@TestOnly
public static Logger getLog() { return LOG; }
@TestOnly
public void startup(@Nullable final Runnable notifier) throws IOException {
final Application app = ApplicationManager.getApplication();
assert app != null && app.isUnitTestMode() : app;
myIsShuttingDown = false;
myStartAttemptCount = 0;
startupProcess(false);
myNotifier = notifier;
}
@TestOnly
public void shutdown() throws InterruptedException {
final Application app = ApplicationManager.getApplication();
assert app != null && app.isUnitTestMode() : app;
myNotifier = null;
final MyProcessHandler processHandler = myProcessHandler;
if (processHandler != null) {
myIsShuttingDown = true;
shutdownProcess();
long t = System.currentTimeMillis();
while (!processHandler.isProcessTerminated()) {
if ((System.currentTimeMillis() - t) > 5000) {
throw new InterruptedException("Timed out waiting watcher process to terminate");
}
TimeoutUtil.sleep(100);
}
}
}
}