diff --git a/src/compiler/sys.ts b/src/compiler/sys.ts index 7049821c90f..50080e0da7b 100644 --- a/src/compiler/sys.ts +++ b/src/compiler/sys.ts @@ -304,6 +304,53 @@ namespace ts { } } + /* @internal */ + export function createSingleFileWatcherPerName( + watchFile: HostWatchFile, + useCaseSensitiveFileNames: boolean + ): HostWatchFile { + interface SingleFileWatcher { + watcher: FileWatcher; + refCount: number; + } + const cache = createMap(); + const callbacksCache = createMultiMap(); + const toCanonicalFileName = createGetCanonicalFileName(useCaseSensitiveFileNames); + + return (fileName, callback, pollingInterval) => { + const path = toCanonicalFileName(fileName); + const existing = cache.get(path); + if (existing) { + existing.refCount++; + } + else { + cache.set(path, { + watcher: watchFile( + fileName, + (fileName, eventKind) => forEach( + callbacksCache.get(path), + cb => cb(fileName, eventKind) + ), + pollingInterval + ), + refCount: 1 + }); + } + callbacksCache.add(path, callback); + + return { + close: () => { + const watcher = Debug.assertDefined(cache.get(path)); + callbacksCache.remove(path, callback); + watcher.refCount--; + if (watcher.refCount) return; + cache.delete(path); + closeFileWatcherOf(watcher); + } + }; + }; + } + /** * Returns true if file status changed */ @@ -695,6 +742,7 @@ namespace ts { const useNonPollingWatchers = process.env.TSC_NONPOLLING_WATCHER; const tscWatchFile = process.env.TSC_WATCHFILE; const tscWatchDirectory = process.env.TSC_WATCHDIRECTORY; + const fsWatchFile = createSingleFileWatcherPerName(fsWatchFileWorker, useCaseSensitiveFileNames); let dynamicPollingWatchFile: HostWatchFile | undefined; const nodeSystem: System = { args: process.argv.slice(2), @@ -835,7 +883,7 @@ namespace ts { return useNonPollingWatchers ? createNonPollingWatchFile() : // Default to do not use polling interval as it is before this experiment branch - (fileName, callback) => fsWatchFile(fileName, callback); + (fileName, callback) => fsWatchFile(fileName, callback, /*pollingInterval*/ undefined); } function getWatchDirectory(): HostWatchDirectory { @@ -916,7 +964,7 @@ namespace ts { } } - function fsWatchFile(fileName: string, callback: FileWatcherCallback, pollingInterval?: number): FileWatcher { + function fsWatchFileWorker(fileName: string, callback: FileWatcherCallback, pollingInterval?: number): FileWatcher { _fs.watchFile(fileName, { persistent: true, interval: pollingInterval || 250 }, fileChanged); let eventKind: FileWatcherEventKind; return { diff --git a/src/harness/virtualFileSystemWithWatch.ts b/src/harness/virtualFileSystemWithWatch.ts index 0535d832374..13b87e1c91a 100644 --- a/src/harness/virtualFileSystemWithWatch.ts +++ b/src/harness/virtualFileSystemWithWatch.ts @@ -314,6 +314,11 @@ interface Array {}` invokeFileDeleteCreateAsPartInsteadOfChange: boolean; } + export enum Tsc_WatchFile { + DynamicPolling = "DynamicPriorityPolling", + SingleFileWatcherPerName = "SingleFileWatcherPerName" + } + export enum Tsc_WatchDirectory { WatchFile = "RecursiveDirectoryUsingFsWatchFile", NonRecursiveWatchDirectory = "RecursiveDirectoryUsingNonRecursiveWatchDirectory", @@ -339,7 +344,7 @@ interface Array {}` readonly watchedFiles = createMultiMap(); private readonly executingFilePath: string; private readonly currentDirectory: string; - private readonly dynamicPriorityWatchFile: HostWatchFile | undefined; + private readonly customWatchFile: HostWatchFile | undefined; private readonly customRecursiveWatchDirectory: HostWatchDirectory | undefined; public require: ((initialPath: string, moduleName: string) => server.RequireResult) | undefined; @@ -349,9 +354,23 @@ interface Array {}` this.executingFilePath = this.getHostSpecificPath(executingFilePath); this.currentDirectory = this.getHostSpecificPath(currentDirectory); this.reloadFS(fileOrFolderorSymLinkList); - this.dynamicPriorityWatchFile = this.environmentVariables && this.environmentVariables.get("TSC_WATCHFILE") === "DynamicPriorityPolling" ? - createDynamicPriorityPollingWatchFile(this) : - undefined; + const tscWatchFile = this.environmentVariables && this.environmentVariables.get("TSC_WATCHFILE") as Tsc_WatchFile; + switch (tscWatchFile) { + case Tsc_WatchFile.DynamicPolling: + this.customWatchFile = createDynamicPriorityPollingWatchFile(this); + break; + case Tsc_WatchFile.SingleFileWatcherPerName: + this.customWatchFile = createSingleFileWatcherPerName( + this.watchFileWorker.bind(this), + this.useCaseSensitiveFileNames + ); + break; + case undefined: + break; + default: + Debug.assertNever(tscWatchFile); + } + const tscWatchDirectory = this.environmentVariables && this.environmentVariables.get("TSC_WATCHDIRECTORY") as Tsc_WatchDirectory; if (tscWatchDirectory === Tsc_WatchDirectory.WatchFile) { const watchDirectory: HostWatchDirectory = (directory, cb) => this.watchFile(directory, () => cb(directory), PollingInterval.Medium); @@ -854,10 +873,14 @@ interface Array {}` } watchFile(fileName: string, cb: FileWatcherCallback, pollingInterval: number) { - if (this.dynamicPriorityWatchFile) { - return this.dynamicPriorityWatchFile(fileName, cb, pollingInterval); + if (this.customWatchFile) { + return this.customWatchFile(fileName, cb, pollingInterval); } + return this.watchFileWorker(fileName, cb); + } + + private watchFileWorker(fileName: string, cb: FileWatcherCallback) { const path = this.toFullPath(fileName); const callback: TestFileWatcher = { fileName, cb }; this.watchedFiles.add(path, callback); diff --git a/src/testRunner/unittests/tsbuild/watchEnvironment.ts b/src/testRunner/unittests/tsbuild/watchEnvironment.ts index f97c92a89d4..d20dd7a0022 100644 --- a/src/testRunner/unittests/tsbuild/watchEnvironment.ts +++ b/src/testRunner/unittests/tsbuild/watchEnvironment.ts @@ -1,111 +1,124 @@ namespace ts.tscWatch { describe("unittests:: tsbuild:: watchEnvironment:: tsbuild:: watchMode:: with different watch environments", () => { - it("watchFile on same file multiple times because file is part of multiple projects", () => { - const project = `${TestFSWithWatch.tsbuildProjectsLocation}/myproject`; - let maxPkgs = 4; - const configPath = `${project}/tsconfig.json`; - const typing: File = { - path: `${project}/typings/xterm.d.ts`, - content: "export const typing = 10;" - }; + describe("when watchFile can create multiple watchers per file", () => { + verifyWatchFileOnMultipleProjects(/*singleWatchPerFile*/ false); + }); - const allPkgFiles = pkgs(pkgFiles); - const system = createWatchedSystem([libFile, typing, ...flatArray(allPkgFiles)], { currentDirectory: project }); - writePkgReferences(); - const host = createSolutionBuilderWithWatchHost(system); - const solutionBuilder = createSolutionBuilderWithWatch(host, ["tsconfig.json"], { watch: true, verbose: true }); - solutionBuilder.build(); - checkOutputErrorsInitial(system, emptyArray, /*disableConsoleClears*/ undefined, [ - `Projects in this build: \r\n${ - concatenate( - pkgs(index => ` * pkg${index}/tsconfig.json`), - [" * tsconfig.json"] - ).join("\r\n")}\n\n`, - ...flatArray(pkgs(index => [ - `Project 'pkg${index}/tsconfig.json' is out of date because output file 'pkg${index}/index.js' does not exist\n\n`, - `Building project '${project}/pkg${index}/tsconfig.json'...\n\n` - ])) - ]); + describe("when watchFile is single watcher per file", () => { + verifyWatchFileOnMultipleProjects( + /*singleWatchPerFile*/ true, + arrayToMap(["TSC_WATCHFILE"], identity, () => TestFSWithWatch.Tsc_WatchFile.SingleFileWatcherPerName) + ); + }); - const watchFilesDetailed = arrayToMap(flatArray(allPkgFiles), f => f.path, () => 1); - watchFilesDetailed.set(configPath, 1); - watchFilesDetailed.set(typing.path, maxPkgs); - checkWatchedFilesDetailed(system, watchFilesDetailed); - system.writeFile(typing.path, `${typing.content}export const typing1 = 10;`); - verifyInvoke(); + function verifyWatchFileOnMultipleProjects(singleWatchPerFile: boolean, environmentVariables?: Map) { + it("watchFile on same file multiple times because file is part of multiple projects", () => { + const project = `${TestFSWithWatch.tsbuildProjectsLocation}/myproject`; + let maxPkgs = 4; + const configPath = `${project}/tsconfig.json`; + const typing: File = { + path: `${project}/typings/xterm.d.ts`, + content: "export const typing = 10;" + }; - // Make change - maxPkgs--; - writePkgReferences(); - system.checkTimeoutQueueLengthAndRun(1); - checkOutputErrorsIncremental(system, emptyArray); - const lastFiles = last(allPkgFiles); - lastFiles.forEach(f => watchFilesDetailed.delete(f.path)); - watchFilesDetailed.set(typing.path, maxPkgs); - checkWatchedFilesDetailed(system, watchFilesDetailed); - system.writeFile(typing.path, typing.content); - verifyInvoke(); - - // Make change to remove all the watches - maxPkgs = 0; - writePkgReferences(); - system.checkTimeoutQueueLengthAndRun(1); - checkOutputErrorsIncremental(system, [ - `tsconfig.json(1,10): error TS18002: The 'files' list in config file '${configPath}' is empty.\n` - ]); - checkWatchedFilesDetailed(system, [configPath], 1); - - system.writeFile(typing.path, `${typing.content}export const typing1 = 10;`); - system.checkTimeoutQueueLength(0); - - function flatArray(arr: T[][]): readonly T[] { - return flatMap(arr, identity); - } - function pkgs(cb: (index: number) => T): T[] { - const result: T[] = []; - for (let index = 0; index < maxPkgs; index++) { - result.push(cb(index)); - } - return result; - } - function createPkgReference(index: number) { - return { path: `./pkg${index}` }; - } - function pkgFiles(index: number): File[] { - return [ - { - path: `${project}/pkg${index}/index.ts`, - content: `export const pkg${index} = ${index};` - }, - { - path: `${project}/pkg${index}/tsconfig.json`, - content: JSON.stringify({ - complerOptions: { composite: true }, - include: [ - "**/*.ts", - "../typings/xterm.d.ts" - ] - }) - } - ]; - } - function writePkgReferences() { - system.writeFile(configPath, JSON.stringify({ - files: [], - include: [], - references: pkgs(createPkgReference) - })); - } - function verifyInvoke() { - pkgs(() => system.checkTimeoutQueueLengthAndRun(1)); - checkOutputErrorsIncremental(system, emptyArray, /*disableConsoleClears*/ undefined, /*logsBeforeWatchDiagnostics*/ undefined, [ + const allPkgFiles = pkgs(pkgFiles); + const system = createWatchedSystem([libFile, typing, ...flatArray(allPkgFiles)], { currentDirectory: project, environmentVariables }); + writePkgReferences(); + const host = createSolutionBuilderWithWatchHost(system); + const solutionBuilder = createSolutionBuilderWithWatch(host, ["tsconfig.json"], { watch: true, verbose: true }); + solutionBuilder.build(); + checkOutputErrorsInitial(system, emptyArray, /*disableConsoleClears*/ undefined, [ + `Projects in this build: \r\n${ + concatenate( + pkgs(index => ` * pkg${index}/tsconfig.json`), + [" * tsconfig.json"] + ).join("\r\n")}\n\n`, ...flatArray(pkgs(index => [ - `Project 'pkg${index}/tsconfig.json' is out of date because oldest output 'pkg${index}/index.js' is older than newest input 'typings/xterm.d.ts'\n\n`, - `Building project '${project}/pkg${index}/tsconfig.json'...\n\n`, - `Updating unchanged output timestamps of project '${project}/pkg${index}/tsconfig.json'...\n\n` + `Project 'pkg${index}/tsconfig.json' is out of date because output file 'pkg${index}/index.js' does not exist\n\n`, + `Building project '${project}/pkg${index}/tsconfig.json'...\n\n` ])) ]); - } - }); + + const watchFilesDetailed = arrayToMap(flatArray(allPkgFiles), f => f.path, () => 1); + watchFilesDetailed.set(configPath, 1); + watchFilesDetailed.set(typing.path, singleWatchPerFile ? 1 : maxPkgs); + checkWatchedFilesDetailed(system, watchFilesDetailed); + system.writeFile(typing.path, `${typing.content}export const typing1 = 10;`); + verifyInvoke(); + + // Make change + maxPkgs--; + writePkgReferences(); + system.checkTimeoutQueueLengthAndRun(1); + checkOutputErrorsIncremental(system, emptyArray); + const lastFiles = last(allPkgFiles); + lastFiles.forEach(f => watchFilesDetailed.delete(f.path)); + watchFilesDetailed.set(typing.path, singleWatchPerFile ? 1 : maxPkgs); + checkWatchedFilesDetailed(system, watchFilesDetailed); + system.writeFile(typing.path, typing.content); + verifyInvoke(); + + // Make change to remove all the watches + maxPkgs = 0; + writePkgReferences(); + system.checkTimeoutQueueLengthAndRun(1); + checkOutputErrorsIncremental(system, [ + `tsconfig.json(1,10): error TS18002: The 'files' list in config file '${configPath}' is empty.\n` + ]); + checkWatchedFilesDetailed(system, [configPath], 1); + + system.writeFile(typing.path, `${typing.content}export const typing1 = 10;`); + system.checkTimeoutQueueLength(0); + + function flatArray(arr: T[][]): readonly T[] { + return flatMap(arr, identity); + } + function pkgs(cb: (index: number) => T): T[] { + const result: T[] = []; + for (let index = 0; index < maxPkgs; index++) { + result.push(cb(index)); + } + return result; + } + function createPkgReference(index: number) { + return { path: `./pkg${index}` }; + } + function pkgFiles(index: number): File[] { + return [ + { + path: `${project}/pkg${index}/index.ts`, + content: `export const pkg${index} = ${index};` + }, + { + path: `${project}/pkg${index}/tsconfig.json`, + content: JSON.stringify({ + complerOptions: { composite: true }, + include: [ + "**/*.ts", + "../typings/xterm.d.ts" + ] + }) + } + ]; + } + function writePkgReferences() { + system.writeFile(configPath, JSON.stringify({ + files: [], + include: [], + references: pkgs(createPkgReference) + })); + } + function verifyInvoke() { + pkgs(() => system.checkTimeoutQueueLengthAndRun(1)); + checkOutputErrorsIncremental(system, emptyArray, /*disableConsoleClears*/ undefined, /*logsBeforeWatchDiagnostics*/ undefined, [ + ...flatArray(pkgs(index => [ + `Project 'pkg${index}/tsconfig.json' is out of date because oldest output 'pkg${index}/index.js' is older than newest input 'typings/xterm.d.ts'\n\n`, + `Building project '${project}/pkg${index}/tsconfig.json'...\n\n`, + `Updating unchanged output timestamps of project '${project}/pkg${index}/tsconfig.json'...\n\n` + ])) + ]); + } + }); + } }); } diff --git a/src/testRunner/unittests/tscWatch/watchEnvironment.ts b/src/testRunner/unittests/tscWatch/watchEnvironment.ts index d30e5854652..ad07d8d1610 100644 --- a/src/testRunner/unittests/tscWatch/watchEnvironment.ts +++ b/src/testRunner/unittests/tscWatch/watchEnvironment.ts @@ -9,7 +9,7 @@ namespace ts.tscWatch { }; const files = [file1, libFile]; const environmentVariables = createMap(); - environmentVariables.set("TSC_WATCHFILE", "DynamicPriorityPolling"); + environmentVariables.set("TSC_WATCHFILE", TestFSWithWatch.Tsc_WatchFile.DynamicPolling); const host = createWatchedSystem(files, { environmentVariables }); const watch = createWatchOfFilesAndCompilerOptions([file1.path], host);