Skip to content

Fix too many watcher instances issue #6026

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Jan 14, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/compiler/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,9 @@ namespace ts {
return path.substr(0, rootLength) + normalized.join(directorySeparator);
}

export function getDirectoryPath(path: string) {
export function getDirectoryPath(path: Path): Path;
export function getDirectoryPath(path: string): string;
export function getDirectoryPath(path: string): any {
return path.substr(0, Math.max(getRootLength(path), path.lastIndexOf(directorySeparator)));
}

Expand Down Expand Up @@ -870,4 +872,11 @@ namespace ts {
}
return copiedList;
}

export function createGetCanonicalFileName(useCaseSensitivefileNames: boolean): (fileName: string) => string {
return useCaseSensitivefileNames
? ((fileName) => fileName)
: ((fileName) => fileName.toLowerCase());
}

}
146 changes: 124 additions & 22 deletions src/compiler/sys.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
/// <reference path="core.ts"/>

namespace ts {
export type FileWatcherCallback = (path: string, removed?: boolean) => void;
export type DirectoryWatcherCallback = (path: string) => void;

export interface System {
args: string[];
newLine: string;
useCaseSensitiveFileNames: boolean;
write(s: string): void;
readFile(path: string, encoding?: string): string;
writeFile(path: string, data: string, writeByteOrderMark?: boolean): void;
watchFile?(path: string, callback: (path: string, removed?: boolean) => void): FileWatcher;
watchDirectory?(path: string, callback: (path: string) => void, recursive?: boolean): FileWatcher;
watchFile?(path: Path, callback: FileWatcherCallback): FileWatcher;
watchDirectory?(path: string, callback: DirectoryWatcherCallback, recursive?: boolean): FileWatcher;
resolvePath(path: string): string;
fileExists(path: string): boolean;
directoryExists(path: string): boolean;
Expand All @@ -22,15 +25,20 @@ namespace ts {
}

interface WatchedFile {
fileName: string;
callback: (fileName: string, removed?: boolean) => void;
mtime: Date;
filePath: Path;
callback: FileWatcherCallback;
mtime?: Date;
}

export interface FileWatcher {
close(): void;
}

export interface DirectoryWatcher extends FileWatcher {
directoryPath: Path;
referenceCount: number;
}

declare var require: any;
declare var module: any;
declare var process: any;
Expand Down Expand Up @@ -62,8 +70,8 @@ namespace ts {
readFile(path: string): string;
writeFile(path: string, contents: string): void;
readDirectory(path: string, extension?: string, exclude?: string[]): string[];
watchFile?(path: string, callback: (path: string, removed?: boolean) => void): FileWatcher;
watchDirectory?(path: string, callback: (path: string) => void, recursive?: boolean): FileWatcher;
watchFile?(path: string, callback: FileWatcherCallback): FileWatcher;
watchDirectory?(path: string, callback: DirectoryWatcherCallback, recursive?: boolean): FileWatcher;
};

export var sys: System = (function () {
Expand Down Expand Up @@ -221,7 +229,7 @@ namespace ts {

// average async stat takes about 30 microseconds
// set chunk size to do 30 files in < 1 millisecond
function createWatchedFileSet(interval = 2500, chunkSize = 30) {
function createPollingWatchedFileSet(interval = 2500, chunkSize = 30) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so why do we need this Polling one, why can not we just fall back to fs.watchFile?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our polling mechanism is different from fs.watchFile. fs.watchFile polls each file independently, so if you got many files to watch, it might increase the work load a lot. In our approach, we poll a fixed number of files (chunkSize) each number, so if the chunkSize is 30 and you have 90 files to watch, the work load is still the same for each polling, only that changes will be detected after 3 interval time instead of just 1. This way we keep the polling work load consistent

let watchedFiles: WatchedFile[] = [];
let nextFileToCheck = 0;
let watchTimer: any;
Expand All @@ -236,13 +244,13 @@ namespace ts {
return;
}

_fs.stat(watchedFile.fileName, (err: any, stats: any) => {
_fs.stat(watchedFile.filePath, (err: any, stats: any) => {
if (err) {
watchedFile.callback(watchedFile.fileName);
watchedFile.callback(watchedFile.filePath);
}
else if (watchedFile.mtime.getTime() !== stats.mtime.getTime()) {
watchedFile.mtime = getModifiedTime(watchedFile.fileName);
watchedFile.callback(watchedFile.fileName, watchedFile.mtime.getTime() === 0);
watchedFile.mtime = getModifiedTime(watchedFile.filePath);
watchedFile.callback(watchedFile.filePath, watchedFile.mtime.getTime() === 0);
}
});
}
Expand Down Expand Up @@ -270,11 +278,11 @@ namespace ts {
}, interval);
}

function addFile(fileName: string, callback: (fileName: string, removed?: boolean) => void): WatchedFile {
function addFile(filePath: Path, callback: FileWatcherCallback): WatchedFile {
const file: WatchedFile = {
fileName,
filePath,
callback,
mtime: getModifiedTime(fileName)
mtime: getModifiedTime(filePath)
};

watchedFiles.push(file);
Expand All @@ -297,6 +305,88 @@ namespace ts {
};
}

function createWatchedFileSet() {
const dirWatchers = createFileMap<DirectoryWatcher>();
// One file can have multiple watchers
const fileWatcherCallbacks = createFileMap<FileWatcherCallback[]>();
return { addFile, removeFile };

function reduceDirWatcherRefCountForFile(filePath: Path) {
const dirPath = getDirectoryPath(filePath);
if (dirWatchers.contains(dirPath)) {
const watcher = dirWatchers.get(dirPath);
watcher.referenceCount -= 1;
if (watcher.referenceCount <= 0) {
watcher.close();
dirWatchers.remove(dirPath);
}
}
}

function addDirWatcher(dirPath: Path): void {
if (dirWatchers.contains(dirPath)) {
const watcher = dirWatchers.get(dirPath);
watcher.referenceCount += 1;
return;
}

const watcher: DirectoryWatcher = _fs.watch(
dirPath,
{ persistent: true },
(eventName: string, relativeFileName: string) => fileEventHandler(eventName, relativeFileName, dirPath)
);
watcher.referenceCount = 1;
dirWatchers.set(dirPath, watcher);
return;
}

function addFileWatcherCallback(filePath: Path, callback: FileWatcherCallback): void {
if (fileWatcherCallbacks.contains(filePath)) {
fileWatcherCallbacks.get(filePath).push(callback);
}
else {
fileWatcherCallbacks.set(filePath, [callback]);
}
}

function addFile(filePath: Path, callback: FileWatcherCallback): WatchedFile {
addFileWatcherCallback(filePath, callback);
addDirWatcher(getDirectoryPath(filePath));

return { filePath, callback };
}

function removeFile(watchedFile: WatchedFile) {
removeFileWatcherCallback(watchedFile.filePath, watchedFile.callback);
reduceDirWatcherRefCountForFile(watchedFile.filePath);
}

function removeFileWatcherCallback(filePath: Path, callback: FileWatcherCallback) {
if (fileWatcherCallbacks.contains(filePath)) {
const newCallbacks = copyListRemovingItem(callback, fileWatcherCallbacks.get(filePath));
if (newCallbacks.length === 0) {
fileWatcherCallbacks.remove(filePath);
}
else {
fileWatcherCallbacks.set(filePath, newCallbacks);
}
}
}

/**
* @param watcherPath is the path from which the watcher is triggered.
*/
function fileEventHandler(eventName: string, relativefileName: string, baseDirPath: Path) {
// When files are deleted from disk, the triggered "rename" event would have a relativefileName of "undefined"
const filePath = relativefileName === undefined ? undefined : toPath(relativefileName, baseDirPath, getCanonicalPath);
if (eventName === "change" && fileWatcherCallbacks.contains(filePath)) {
for (const fileCallback of fileWatcherCallbacks.get(filePath)) {
fileCallback(filePath);
}
}
}
}

// REVIEW: for now this implementation uses polling.
// The advantage of polling is that it works reliably
// on all os and with network mounted files.
Expand All @@ -310,8 +400,13 @@ namespace ts {
// changes for large reference sets? If so, do we want
// to increase the chunk size or decrease the interval
// time dynamically to match the large reference set?
const pollingWatchedFileSet = createPollingWatchedFileSet();
const watchedFileSet = createWatchedFileSet();

function isNode4OrLater(): boolean {
return parseInt(process.version.charAt(1)) >= 4;
}

const platform: string = _os.platform();
// win32\win64 are case insensitive platforms, MacOS (darwin) by default is also case insensitive
const useCaseSensitiveFileNames = platform !== "win32" && platform !== "win64" && platform !== "darwin";
Expand Down Expand Up @@ -405,29 +500,38 @@ namespace ts {
},
readFile,
writeFile,
watchFile: (fileName, callback) => {
watchFile: (filePath, callback) => {
// Node 4.0 stablized the `fs.watch` function on Windows which avoids polling
// and is more efficient than `fs.watchFile` (ref: https://github.com/nodejs/node/pull/2649
// and https://github.com/Microsoft/TypeScript/issues/4643), therefore
// if the current node.js version is newer than 4, use `fs.watch` instead.
const watchedFile = watchedFileSet.addFile(fileName, callback);
const watchSet = isNode4OrLater() ? watchedFileSet : pollingWatchedFileSet;
const watchedFile = watchSet.addFile(filePath, callback);
return {
close: () => watchedFileSet.removeFile(watchedFile)
close: () => watchSet.removeFile(watchedFile)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"watchDirectory" (line 474 below) also talks about Node 4 or later and uses the 'recursive' option, but doesn't actually check for Node 4 or later.

I can see in "tsc.ts" and "editorServices.ts" it is called with "recursive: true". Are there potentially issues here if not on Node v4?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested before that if not supported then the recursive: true would simply be ignored. The recursive directory watcher is used with tsconfig.json without files array, so that adding/removing files in the background would be detected. The plan for this feature at the time was to support Node v4 or later only, so I didn't differentiate with the cases with older Node version.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure i follow about the plan to use the feature. watchDirectory is a public API (exported on interface System). If when called, it's not going to be able to do what the API claims it does (via the recursive parameter`), then it should error.

Even if not a public API, it's a future bug waiting to happen if it ignores known failure scenarios based on assumptions about how it will be used. Please protect against it.

};
},
watchDirectory: (path, callback, recursive) => {
// Node 4.0 `fs.watch` function supports the "recursive" option on both OSX and Windows
// (ref: https://github.com/nodejs/node/pull/2649 and https://github.com/Microsoft/TypeScript/issues/4643)
let options: any;
if (isNode4OrLater() && (process.platform === "win32" || process.platform === "darwin")) {
options = { persistent: true, recursive: !!recursive };
}
else {
options = { persistent: true };
}

return _fs.watch(
path,
{ persistent: true, recursive: !!recursive },
options,
(eventName: string, relativeFileName: string) => {
// In watchDirectory we only care about adding and removing files (when event name is
// "rename"); changes made within files are handled by corresponding fileWatchers (when
// event name is "change")
if (eventName === "rename") {
// When deleting a file, the passed baseFileName is null
callback(!relativeFileName ? relativeFileName : normalizePath(ts.combinePaths(path, relativeFileName)));
callback(!relativeFileName ? relativeFileName : normalizePath(combinePaths(path, relativeFileName)));
};
}
);
Expand Down Expand Up @@ -511,5 +615,3 @@ namespace ts {
}
})();
}


6 changes: 4 additions & 2 deletions src/compiler/tsc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,8 @@ namespace ts {
return sys.exit(ExitStatus.DiagnosticsPresent_OutputsSkipped);
}
if (configFileName) {
configFileWatcher = sys.watchFile(configFileName, configFileChanged);
const configFilePath = toPath(configFileName, sys.getCurrentDirectory(), createGetCanonicalFileName(sys.useCaseSensitiveFileNames));
configFileWatcher = sys.watchFile(configFilePath, configFileChanged);
}
if (sys.watchDirectory && configFileName) {
const directory = ts.getDirectoryPath(configFileName);
Expand Down Expand Up @@ -442,7 +443,8 @@ namespace ts {
const sourceFile = hostGetSourceFile(fileName, languageVersion, onError);
if (sourceFile && compilerOptions.watch) {
// Attach a file watcher
sourceFile.fileWatcher = sys.watchFile(sourceFile.fileName, (fileName: string, removed?: boolean) => sourceFileChanged(sourceFile, removed));
const filePath = toPath(sourceFile.fileName, sys.getCurrentDirectory(), createGetCanonicalFileName(sys.useCaseSensitiveFileNames));
sourceFile.fileWatcher = sys.watchFile(filePath, (fileName: string, removed?: boolean) => sourceFileChanged(sourceFile, removed));
}
return sourceFile;
}
Expand Down
4 changes: 2 additions & 2 deletions src/server/editorServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1000,7 +1000,7 @@ namespace ts.server {
info.setFormatOptions(this.getFormatCodeOptions());
this.filenameToScriptInfo[fileName] = info;
if (!info.isOpen) {
info.fileWatcher = this.host.watchFile(fileName, _ => { this.watchedFileChanged(fileName); });
info.fileWatcher = this.host.watchFile(<Path>fileName, _ => { this.watchedFileChanged(fileName); });
}
}
}
Expand Down Expand Up @@ -1213,7 +1213,7 @@ namespace ts.server {
}
}
project.finishGraph();
project.projectFileWatcher = this.host.watchFile(configFilename, _ => this.watchedProjectConfigFileChanged(project));
project.projectFileWatcher = this.host.watchFile(<Path>configFilename, _ => this.watchedProjectConfigFileChanged(project));
this.log("Add recursive watcher for: " + ts.getDirectoryPath(configFilename));
project.directoryWatcher = this.host.watchDirectory(
ts.getDirectoryPath(configFilename),
Expand Down
7 changes: 0 additions & 7 deletions src/services/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2008,13 +2008,6 @@ namespace ts {
return createLanguageServiceSourceFile(sourceFile.fileName, scriptSnapshot, sourceFile.languageVersion, version, /*setNodeParents*/ true);
}

export function createGetCanonicalFileName(useCaseSensitivefileNames: boolean): (fileName: string) => string {
return useCaseSensitivefileNames
? ((fileName) => fileName)
: ((fileName) => fileName.toLowerCase());
}


export function createDocumentRegistry(useCaseSensitiveFileNames?: boolean, currentDirectory = ""): DocumentRegistry {
// Maps from compiler setting target (ES3, ES5, etc.) to all the cached documents we have
// for those settings.
Expand Down