Skip to content

Commit ec62747

Browse files
bradzacherJamesHenry
authored andcommitted
fix(typescript-estree): handle running out of fs watchers (#1088)
1 parent 5f093ac commit ec62747

File tree

5 files changed

+137
-50
lines changed

5 files changed

+137
-50
lines changed

Diff for: packages/typescript-estree/README.md

+5
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,11 @@ I work closely with the TypeScript Team and we are gradually aliging the AST of
144144
- `npm run unit-tests` - run only unit tests
145145
- `npm run ast-alignment-tests` - run only Babylon AST alignment tests
146146

147+
## Debugging
148+
149+
If you encounter a bug with the parser that you want to investigate, you can turn on the debug logging via setting the environment variable: `DEBUG=typescript-eslint:*`.
150+
I.e. in this repo you can run: `DEBUG=typescript-eslint:* yarn lint`.
151+
147152
## License
148153

149154
TypeScript ESTree inherits from the the original TypeScript ESLint Parser license, as the majority of the work began there. It is licensed under a permissive BSD 2-clause license.

Diff for: packages/typescript-estree/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
},
4141
"dependencies": {
4242
"chokidar": "^3.0.2",
43+
"debug": "^4.1.1",
4344
"glob": "^7.1.4",
4445
"is-glob": "^4.0.1",
4546
"lodash.unescape": "4.0.1",
@@ -50,6 +51,7 @@
5051
"@babel/parser": "7.5.5",
5152
"@babel/types": "^7.3.2",
5253
"@types/babel-code-frame": "^6.20.1",
54+
"@types/debug": "^4.1.5",
5355
"@types/glob": "^7.1.1",
5456
"@types/is-glob": "^4.0.1",
5557
"@types/lodash.isplainobject": "^4.0.4",

Diff for: packages/typescript-estree/src/parser.ts

+42-8
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import debug from 'debug';
12
import path from 'path';
23
import semver from 'semver';
34
import * as ts from 'typescript'; // leave this as * as ts so people using util package don't need syntheticDefaultImports
@@ -15,6 +16,8 @@ import {
1516
defaultCompilerOptions,
1617
} from './tsconfig-parser';
1718

19+
const log = debug('typescript-eslint:typescript-estree:parser');
20+
1821
/**
1922
* This needs to be kept in sync with the top-level README.md in the
2023
* typescript-eslint monorepo
@@ -41,6 +44,17 @@ function getFileName({ jsx }: { jsx?: boolean }): string {
4144
return jsx ? 'estree.tsx' : 'estree.ts';
4245
}
4346

47+
function enforceString(code: unknown): string {
48+
/**
49+
* Ensure the source code is a string
50+
*/
51+
if (typeof code !== 'string') {
52+
return String(code);
53+
}
54+
55+
return code;
56+
}
57+
4458
/**
4559
* Resets the extra config object
4660
*/
@@ -82,6 +96,8 @@ function getASTFromProject(
8296
options: TSESTreeOptions,
8397
createDefaultProgram: boolean,
8498
): ASTAndProgram | undefined {
99+
log('Attempting to get AST from project(s) for: %s', options.filePath);
100+
85101
const filePath = options.filePath || getFileName(options);
86102
const astAndProgram = firstDefined(
87103
calculateProjectParserOptions(code, filePath, extra),
@@ -139,6 +155,11 @@ function getASTAndDefaultProject(
139155
code: string,
140156
options: TSESTreeOptions,
141157
): ASTAndProgram | undefined {
158+
log(
159+
'Attempting to get AST from the default project(s): %s',
160+
options.filePath,
161+
);
162+
142163
const fileName = options.filePath || getFileName(options);
143164
const program = createProgram(code, fileName, extra);
144165
const ast = program && program.getSourceFile(fileName);
@@ -150,6 +171,8 @@ function getASTAndDefaultProject(
150171
* @returns Returns a new source file and program corresponding to the linted code
151172
*/
152173
function createNewProgram(code: string): ASTAndProgram {
174+
log('Getting AST without type information');
175+
153176
const FILENAME = getFileName(extra);
154177

155178
const compilerHost: ts.CompilerHost = {
@@ -226,6 +249,9 @@ function getProgramAndAST(
226249
}
227250

228251
function applyParserOptionsToExtra(options: TSESTreeOptions): void {
252+
/**
253+
* Turn on/off filesystem watchers
254+
*/
229255
extra.noWatch = typeof options.noWatch === 'boolean' && options.noWatch;
230256

231257
/**
@@ -378,6 +404,7 @@ export function parse<T extends TSESTreeOptions = TSESTreeOptions>(
378404
* Reset the parse configuration
379405
*/
380406
resetExtra();
407+
381408
/**
382409
* Ensure users do not attempt to use parse() when they need parseAndGenerateServices()
383410
*/
@@ -386,24 +413,25 @@ export function parse<T extends TSESTreeOptions = TSESTreeOptions>(
386413
`"errorOnTypeScriptSyntacticAndSemanticIssues" is only supported for parseAndGenerateServices()`,
387414
);
388415
}
416+
389417
/**
390418
* Ensure the source code is a string, and store a reference to it
391419
*/
392-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
393-
if (typeof code !== 'string' && !((code as any) instanceof String)) {
394-
code = String(code);
395-
}
420+
code = enforceString(code);
396421
extra.code = code;
422+
397423
/**
398424
* Apply the given parser options
399425
*/
400426
if (typeof options !== 'undefined') {
401427
applyParserOptionsToExtra(options);
402428
}
429+
403430
/**
404431
* Warn if the user is using an unsupported version of TypeScript
405432
*/
406433
warnAboutTSVersion();
434+
407435
/**
408436
* Create a ts.SourceFile directly, no ts.Program is needed for a simple
409437
* parse
@@ -414,6 +442,7 @@ export function parse<T extends TSESTreeOptions = TSESTreeOptions>(
414442
ts.ScriptTarget.Latest,
415443
/* setParentNodes */ true,
416444
);
445+
417446
/**
418447
* Convert the TypeScript AST to an ESTree-compatible one
419448
*/
@@ -428,14 +457,13 @@ export function parseAndGenerateServices<
428457
* Reset the parse configuration
429458
*/
430459
resetExtra();
460+
431461
/**
432462
* Ensure the source code is a string, and store a reference to it
433463
*/
434-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
435-
if (typeof code !== 'string' && !((code as any) instanceof String)) {
436-
code = String(code);
437-
}
464+
code = enforceString(code);
438465
extra.code = code;
466+
439467
/**
440468
* Apply the given parser options
441469
*/
@@ -449,10 +477,12 @@ export function parseAndGenerateServices<
449477
extra.errorOnTypeScriptSyntacticAndSemanticIssues = true;
450478
}
451479
}
480+
452481
/**
453482
* Warn if the user is using an unsupported version of TypeScript
454483
*/
455484
warnAboutTSVersion();
485+
456486
/**
457487
* Generate a full ts.Program in order to be able to provide parser
458488
* services, such as type-checking
@@ -465,6 +495,7 @@ export function parseAndGenerateServices<
465495
shouldProvideParserServices,
466496
extra.createDefaultProgram,
467497
)!;
498+
468499
/**
469500
* Determine whether or not two-way maps of converted AST nodes should be preserved
470501
* during the conversion process
@@ -473,11 +504,13 @@ export function parseAndGenerateServices<
473504
extra.preserveNodeMaps !== undefined
474505
? extra.preserveNodeMaps
475506
: shouldProvideParserServices;
507+
476508
/**
477509
* Convert the TypeScript AST to an ESTree-compatible one, and optionally preserve
478510
* mappings between converted and original AST nodes
479511
*/
480512
const { estree, astMaps } = astConverter(ast, extra, shouldPreserveNodeMaps);
513+
481514
/**
482515
* Even if TypeScript parsed the source code ok, and we had no problems converting the AST,
483516
* there may be other syntactic or semantic issues in the code that we can optionally report on.
@@ -488,6 +521,7 @@ export function parseAndGenerateServices<
488521
throw convertError(error);
489522
}
490523
}
524+
491525
/**
492526
* Return the converted AST and additional parser services
493527
*/

Diff for: packages/typescript-estree/src/tsconfig-parser.ts

+80-39
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import chokidar from 'chokidar';
2+
import debug from 'debug';
23
import path from 'path';
34
import * as ts from 'typescript'; // leave this as * as ts so people using util package don't need syntheticDefaultImports
45
import { Extra } from './parser-options';
56
import { WatchCompilerHostOfConfigFile } from './WatchCompilerHostOfConfigFile';
67

7-
//------------------------------------------------------------------------------
8-
// Environment calculation
9-
//------------------------------------------------------------------------------
8+
const log = debug('typescript-eslint:typescript-estree:tsconfig-parser');
109

1110
/**
1211
* Default compiler options for program generation from single root file
@@ -33,16 +32,18 @@ const knownWatchProgramMap = new Map<
3332
*/
3433
const watchCallbackTrackingMap = new Map<string, Set<ts.FileWatcherCallback>>();
3534

36-
/**
37-
* Tracks the ts.sys.watchFile watchers that we've opened for config files.
38-
* We store these so we can clean up our handles if required.
39-
*/
40-
const configSystemFileWatcherTrackingSet = new Set<ts.FileWatcher>();
35+
interface Watcher {
36+
close(): void;
37+
forceClose(): void;
38+
on(evt: 'add', listener: (file: string) => void): void;
39+
on(evt: 'change', listener: (file: string) => void): void;
40+
trackWatcher(): void;
41+
}
4142
/**
4243
* Tracks the ts.sys.watchDirectory watchers that we've opened for project folders.
4344
* We store these so we can clean up our handles if required.
4445
*/
45-
const directorySystemFileWatcherTrackingSet = new Set<ts.FileWatcher>();
46+
const fileWatcherTrackingSet = new Map<string, Watcher>();
4647

4748
const parsedFilesSeen = new Set<string>();
4849

@@ -56,12 +57,8 @@ export function clearCaches(): void {
5657
parsedFilesSeen.clear();
5758

5859
// stop tracking config files
59-
configSystemFileWatcherTrackingSet.forEach(cb => cb.close());
60-
configSystemFileWatcherTrackingSet.clear();
61-
62-
// stop tracking folders
63-
directorySystemFileWatcherTrackingSet.forEach(cb => cb.close());
64-
directorySystemFileWatcherTrackingSet.clear();
60+
fileWatcherTrackingSet.forEach(cb => cb.forceClose());
61+
fileWatcherTrackingSet.clear();
6562
}
6663

6764
/**
@@ -88,34 +85,84 @@ function getTsconfigPath(tsconfigPath: string, extra: Extra): string {
8885
: path.join(extra.tsconfigRootDir || process.cwd(), tsconfigPath);
8986
}
9087

91-
interface Watcher {
92-
close(): void;
93-
on(evt: 'add', listener: (file: string) => void): void;
94-
on(evt: 'change', listener: (file: string) => void): void;
95-
}
88+
const EMPTY_WATCHER: Watcher = {
89+
close: (): void => {},
90+
forceClose: (): void => {},
91+
on: (): void => {},
92+
trackWatcher: (): void => {},
93+
};
94+
9695
/**
9796
* Watches a file or directory for changes
9897
*/
9998
function watch(
100-
path: string,
99+
watchPath: string,
101100
options: chokidar.WatchOptions,
102101
extra: Extra,
103102
): Watcher {
104103
// an escape hatch to disable the file watchers as they can take a bit to initialise in some cases
105104
// this also supports an env variable so it's easy to switch on/off from the CLI
106-
if (process.env.PARSER_NO_WATCH === 'true' || extra.noWatch === true) {
107-
return {
108-
close: (): void => {},
109-
on: (): void => {},
110-
};
105+
const blockWatchers =
106+
process.env.PARSER_NO_WATCH === 'false'
107+
? false
108+
: process.env.PARSER_NO_WATCH === 'true' || extra.noWatch === true;
109+
if (blockWatchers) {
110+
return EMPTY_WATCHER;
111+
}
112+
113+
// reuse watchers in case typescript asks us to watch the same file/directory multiple times
114+
if (fileWatcherTrackingSet.has(watchPath)) {
115+
const watcher = fileWatcherTrackingSet.get(watchPath)!;
116+
watcher.trackWatcher();
117+
return watcher;
111118
}
112119

113-
return chokidar.watch(path, {
114-
ignoreInitial: true,
115-
persistent: false,
116-
useFsEvents: false,
117-
...options,
118-
});
120+
let fsWatcher: chokidar.FSWatcher;
121+
try {
122+
log('setting up watcher on path: %s', watchPath);
123+
fsWatcher = chokidar.watch(watchPath, {
124+
ignoreInitial: true,
125+
persistent: false,
126+
useFsEvents: false,
127+
...options,
128+
});
129+
} catch (e) {
130+
log(
131+
'error occurred using file watcher, setting up polling watcher instead: %s',
132+
watchPath,
133+
);
134+
// https://github.com/microsoft/TypeScript/blob/c9d407b52ad92370cd116105c33d618195de8070/src/compiler/sys.ts#L1232-L1237
135+
// Catch the exception and use polling instead
136+
// Eg. on linux the number of watches are limited and one could easily exhaust watches and the exception ENOSPC is thrown when creating watcher at that point
137+
// so instead of throwing error, use fs.watchFile
138+
fsWatcher = chokidar.watch(watchPath, {
139+
ignoreInitial: true,
140+
persistent: false,
141+
useFsEvents: false,
142+
...options,
143+
usePolling: true,
144+
});
145+
}
146+
147+
let counter = 1;
148+
const watcher = {
149+
close: (): void => {
150+
counter -= 1;
151+
if (counter <= 0) {
152+
fsWatcher.close();
153+
fileWatcherTrackingSet.delete(watchPath);
154+
}
155+
},
156+
forceClose: fsWatcher.close.bind(fsWatcher),
157+
on: fsWatcher.on.bind(fsWatcher),
158+
trackWatcher: (): void => {
159+
counter += 1;
160+
},
161+
};
162+
163+
fileWatcherTrackingSet.set(watchPath, watcher);
164+
165+
return watcher;
119166
}
120167

121168
/**
@@ -219,7 +266,6 @@ export function calculateProjectParserOptions(
219266
watcher.on('change', path => {
220267
callback(path, ts.FileWatcherEventKind.Changed);
221268
});
222-
configSystemFileWatcherTrackingSet.add(watcher);
223269
}
224270

225271
const normalizedFileName = path.normalize(fileName);
@@ -239,7 +285,6 @@ export function calculateProjectParserOptions(
239285

240286
if (watcher) {
241287
watcher.close();
242-
configSystemFileWatcherTrackingSet.delete(watcher);
243288
}
244289
},
245290
};
@@ -263,13 +308,9 @@ export function calculateProjectParserOptions(
263308
watcher.on('add', path => {
264309
callback(path);
265310
});
266-
directorySystemFileWatcherTrackingSet.add(watcher);
267311

268312
return {
269-
close(): void {
270-
watcher.close();
271-
directorySystemFileWatcherTrackingSet.delete(watcher);
272-
},
313+
close: watcher.close,
273314
};
274315
};
275316

0 commit comments

Comments
 (0)