Skip to content

Commit fd0a5e8

Browse files
authored
Improved startup responsiveness on large monorepos (#10786)
* Improved startup responsiveness on large monorepos by making source file discovery code non-blocking. This addresses #3075. * Fixed regression in CLI. * Refactored "enumerateSourceFiles" into a separate method so pylance can call it.
1 parent 8b02aa8 commit fd0a5e8

File tree

2 files changed

+284
-160
lines changed

2 files changed

+284
-160
lines changed

packages/pyright-internal/src/analyzer/service.ts

Lines changed: 86 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,12 @@ import {
3939
FileSpec,
4040
deduplicateFolders,
4141
getFileSpec,
42-
getFileSystemEntries,
4342
hasPythonExtension,
4443
isDirectory,
4544
isFile,
4645
makeDirectories,
47-
tryRealpath,
4846
tryStat,
4947
} from '../common/uri/uriUtils';
50-
import { Localizer } from '../localization/localize';
5148
import { AnalysisCompleteCallback } from './analysis';
5249
import {
5350
BackgroundAnalysisProgram,
@@ -63,6 +60,7 @@ import {
6360
findPyprojectTomlFile,
6461
findPyprojectTomlFileHereOrUp,
6562
} from './serviceUtils';
63+
import { SourceEnumerator } from './sourceEnumerator';
6664
import { IPythonMode } from './sourceFile';
6765

6866
// How long since the last user activity should we wait until running
@@ -128,6 +126,7 @@ export class AnalyzerService {
128126
private _requireTrackedFileUpdate = true;
129127
private _lastUserInteractionTime = 0;
130128
private _backgroundAnalysisCancellationSource: AbstractCancellationTokenSource | undefined;
129+
private _sourceEnumerator: SourceEnumerator | undefined;
131130

132131
private _disposed = false;
133132
private _pendingLibraryChanges: RefreshOptions = { changesOnly: true };
@@ -448,7 +447,16 @@ export class AnalyzerService {
448447
}
449448

450449
test_getFileNamesFromFileSpecs(): Uri[] {
451-
return this._getFileNamesFromFileSpecs();
450+
const enumerator = new SourceEnumerator(
451+
this._configOptions.include,
452+
this._configOptions.exclude,
453+
!!this._configOptions.autoExcludeVenv,
454+
this.fs,
455+
this._console
456+
);
457+
458+
const results = enumerator.enumerate(0);
459+
return this._getTrackedFileList(results.matches);
452460
}
453461

454462
test_shouldHandleSourceFileWatchChanges(uri: Uri, isFile: boolean) {
@@ -547,6 +555,16 @@ export class AnalyzerService {
547555
this._updateTrackedFileList(/* markFilesDirtyUnconditionally */ false);
548556
}
549557

558+
// Continue to enumerate sources if we haven't finished doing so.
559+
// Use the "noOpenFilesTimeInMs" limit if it's provided. Otherwise
560+
// do all enumeration in one shot. The latter is used for the CLI
561+
// and other environments where the user is not blocked on the operation.
562+
const maxSourceEnumeratorTime = this.options.maxAnalysisTime?.noOpenFilesTimeInMs ?? 0;
563+
if (!this.enumerateSourceFiles(maxSourceEnumeratorTime)) {
564+
this.scheduleReanalysis(/* requireTrackedFileUpdate */ false);
565+
return;
566+
}
567+
550568
// Recreate the cancellation token every time we start analysis.
551569
this._backgroundAnalysisCancellationSource = this.cancellationProvider.createCancellationTokenSource();
552570

@@ -556,6 +574,52 @@ export class AnalyzerService {
556574
}, timeUntilNextAnalysisInMs);
557575
}
558576

577+
// Attempts to make progress on source file enumeration if there is an active
578+
// source enumerator associated with the service. Returns true if complete.
579+
protected enumerateSourceFiles(maxSourceEnumeratorTime: number): boolean {
580+
// If there is no active source enumerator, we're done.
581+
if (!this._sourceEnumerator) {
582+
return true;
583+
}
584+
585+
let fileMap: Map<string, Uri>;
586+
587+
if (this._executionRootUri.isEmpty()) {
588+
// No user files for default workspace.
589+
fileMap = new Map<string, Uri>();
590+
} else {
591+
const enumerator = this._sourceEnumerator;
592+
const enumResults = timingStats.findFilesTime.timeOperation(() =>
593+
enumerator.enumerate(maxSourceEnumeratorTime)
594+
);
595+
596+
if (!enumResults.isComplete) {
597+
return false;
598+
}
599+
600+
// Update the config options to include the auto-excluded directories.
601+
const excludes = this.options.configOptions?.exclude;
602+
if (enumResults.autoExcludedDirs && excludes) {
603+
enumResults.autoExcludedDirs.forEach((excludedDir) => {
604+
if (!FileSpec.isInPath(excludedDir, excludes)) {
605+
excludes.push(getFileSpec(this._configOptions.projectRoot, `${excludedDir}/**`));
606+
}
607+
});
608+
this._backgroundAnalysisProgram.setConfigOptions(this._configOptions);
609+
}
610+
611+
fileMap = enumResults.matches;
612+
613+
const fileList = this._getTrackedFileList(fileMap);
614+
this._backgroundAnalysisProgram.setTrackedFiles(fileList);
615+
616+
// Source file enumeration is complete. Proceed with analysis.
617+
this._sourceEnumerator = undefined;
618+
}
619+
620+
return true;
621+
}
622+
559623
protected applyConfigOptions(host: Host) {
560624
// Indicate that we are about to reanalyze because of this config change.
561625
if (this.options.onInvalidated) {
@@ -1267,19 +1331,10 @@ export class AnalyzerService {
12671331
return undefined;
12681332
}
12691333

1270-
private _getFileNamesFromFileSpecs(): Uri[] {
1271-
// Use a map to generate a list of unique files.
1272-
const fileMap = new Map<string, Uri>();
1273-
1274-
// Scan all matching files from file system.
1275-
timingStats.findFilesTime.timeOperation(() => {
1276-
const matchedFiles = this._matchFiles(this._configOptions.include, this._configOptions.exclude);
1277-
1278-
for (const file of matchedFiles) {
1279-
fileMap.set(file.key, file);
1280-
}
1281-
});
1282-
1334+
// Given a file map returned by the source enumerator, this function
1335+
// adds any open files that match the include file spec and returns a
1336+
// final deduped file list.
1337+
private _getTrackedFileList(fileMap: Map<string, Uri>): Uri[] {
12831338
// And scan all matching open files. We need to do this since some of files are not backed by
12841339
// files in file system but only exist in memory (ex, virtual workspace)
12851340
this._backgroundAnalysisProgram.program
@@ -1288,7 +1343,8 @@ export class AnalyzerService {
12881343
.filter((f) => matchFileSpecs(this._program.configOptions, f))
12891344
.forEach((f) => fileMap.set(f.key, f));
12901345

1291-
return Array.from(fileMap.values());
1346+
const fileList = Array.from(fileMap.values());
1347+
return fileList;
12921348
}
12931349

12941350
// If markFilesDirtyUnconditionally is true, we need to reparse
@@ -1362,152 +1418,22 @@ export class AnalyzerService {
13621418
} else {
13631419
this._console.error(`Import '${this._typeStubTargetImportName}' not found`);
13641420
}
1421+
1422+
this._requireTrackedFileUpdate = false;
13651423
} else if (!this.options.skipScanningUserFiles) {
1366-
let fileList: Uri[] = [];
1367-
this._console.log(`Searching for source files`);
1368-
fileList = this._getFileNamesFromFileSpecs();
1424+
// Allocate a new source enumerator. We'll call this
1425+
// repeatedly until all source files are found.
1426+
this._sourceEnumerator = new SourceEnumerator(
1427+
this._configOptions.include,
1428+
this._configOptions.exclude,
1429+
!!this._configOptions.autoExcludeVenv,
1430+
this.fs,
1431+
this._console
1432+
);
13691433

1370-
// getFileNamesFromFileSpecs might have updated configOptions, resync options.
1371-
this._backgroundAnalysisProgram.setConfigOptions(this._configOptions);
1372-
this._backgroundAnalysisProgram.setTrackedFiles(fileList);
13731434
this._backgroundAnalysisProgram.markAllFilesDirty(markFilesDirtyUnconditionally);
1374-
1375-
if (fileList.length === 0) {
1376-
this._console.info(`No source files found.`);
1377-
} else {
1378-
this._console.info(`Found ${fileList.length} ` + `source ${fileList.length === 1 ? 'file' : 'files'}`);
1379-
}
1380-
}
1381-
1382-
this._requireTrackedFileUpdate = false;
1383-
}
1384-
1385-
private _tryShowLongOperationMessageBox() {
1386-
const windowService = this.serviceProvider.tryGet(ServiceKeys.windowService);
1387-
if (!windowService) {
1388-
return;
1389-
}
1390-
1391-
const message = Localizer.Service.longOperation();
1392-
const action = windowService.createGoToOutputAction();
1393-
windowService.showInformationMessage(message, action);
1394-
}
1395-
1396-
private _matchFiles(include: FileSpec[], exclude: FileSpec[]): Uri[] {
1397-
if (this._executionRootUri.isEmpty()) {
1398-
// No user files for default workspace.
1399-
return [];
1435+
this._requireTrackedFileUpdate = false;
14001436
}
1401-
1402-
const envMarkers = [['bin', 'activate'], ['Scripts', 'activate'], ['pyvenv.cfg'], ['conda-meta']];
1403-
const results: Uri[] = [];
1404-
const startTime = Date.now();
1405-
const longOperationLimitInSec = 10;
1406-
const nFilesToSuggestSubfolder = 50;
1407-
1408-
let loggedLongOperationError = false;
1409-
let nFilesVisited = 0;
1410-
1411-
const visitDirectoryUnchecked = (absolutePath: Uri, includeRegExp: RegExp, hasDirectoryWildcard: boolean) => {
1412-
if (!loggedLongOperationError) {
1413-
const secondsSinceStart = (Date.now() - startTime) * 0.001;
1414-
1415-
// If this is taking a long time, log an error to help the user
1416-
// diagnose and mitigate the problem.
1417-
if (secondsSinceStart >= longOperationLimitInSec && nFilesVisited >= nFilesToSuggestSubfolder) {
1418-
this._console.error(
1419-
`Enumeration of workspace source files is taking longer than ${longOperationLimitInSec} seconds.\n` +
1420-
'This may be because:\n' +
1421-
'* You have opened your home directory or entire hard drive as a workspace\n' +
1422-
'* Your workspace contains a very large number of directories and files\n' +
1423-
'* Your workspace contains a symlink to a directory with many files\n' +
1424-
'* Your workspace is remote, and file enumeration is slow\n' +
1425-
'To reduce this time, open a workspace directory with fewer files ' +
1426-
'or add a pyrightconfig.json configuration file with an "exclude" section to exclude ' +
1427-
'subdirectories from your workspace. For more details, refer to ' +
1428-
'https://github.com/microsoft/pyright/blob/main/docs/configuration.md.'
1429-
);
1430-
1431-
// Show it in message box if it is supported.
1432-
this._tryShowLongOperationMessageBox();
1433-
1434-
loggedLongOperationError = true;
1435-
}
1436-
}
1437-
1438-
if (this._configOptions.autoExcludeVenv) {
1439-
if (envMarkers.some((f) => this.fs.existsSync(absolutePath.resolvePaths(...f)))) {
1440-
// Save auto exclude paths in the configOptions once we found them.
1441-
if (!FileSpec.isInPath(absolutePath, exclude)) {
1442-
exclude.push(getFileSpec(this._configOptions.projectRoot, `${absolutePath}/**`));
1443-
}
1444-
1445-
this._console.info(`Auto-excluding ${absolutePath.toUserVisibleString()}`);
1446-
return;
1447-
}
1448-
}
1449-
1450-
const { files, directories } = getFileSystemEntries(this.fs, absolutePath);
1451-
1452-
for (const filePath of files) {
1453-
if (FileSpec.matchIncludeFileSpec(includeRegExp, exclude, filePath)) {
1454-
nFilesVisited++;
1455-
results.push(filePath);
1456-
}
1457-
}
1458-
1459-
for (const dirPath of directories) {
1460-
if (dirPath.matchesRegex(includeRegExp) || hasDirectoryWildcard) {
1461-
if (!FileSpec.isInPath(dirPath, exclude)) {
1462-
visitDirectory(dirPath, includeRegExp, hasDirectoryWildcard);
1463-
}
1464-
}
1465-
}
1466-
};
1467-
1468-
const seenDirs = new Set<string>();
1469-
const visitDirectory = (absolutePath: Uri, includeRegExp: RegExp, hasDirectoryWildcard: boolean) => {
1470-
const realDirPath = tryRealpath(this.fs, absolutePath);
1471-
if (!realDirPath) {
1472-
this._console.warn(`Skipping broken link "${absolutePath}"`);
1473-
return;
1474-
}
1475-
1476-
if (seenDirs.has(realDirPath.key)) {
1477-
this._console.warn(`Skipping recursive symlink "${absolutePath}" -> "${realDirPath}"`);
1478-
return;
1479-
}
1480-
seenDirs.add(realDirPath.key);
1481-
1482-
try {
1483-
visitDirectoryUnchecked(absolutePath, includeRegExp, hasDirectoryWildcard);
1484-
} finally {
1485-
seenDirs.delete(realDirPath.key);
1486-
}
1487-
};
1488-
1489-
include.forEach((includeSpec) => {
1490-
if (!FileSpec.isInPath(includeSpec.wildcardRoot, exclude)) {
1491-
let foundFileSpec = false;
1492-
1493-
const stat = tryStat(this.fs, includeSpec.wildcardRoot);
1494-
if (stat?.isFile()) {
1495-
results.push(includeSpec.wildcardRoot);
1496-
foundFileSpec = true;
1497-
} else if (stat?.isDirectory()) {
1498-
visitDirectory(includeSpec.wildcardRoot, includeSpec.regExp, includeSpec.hasDirectoryWildcard);
1499-
foundFileSpec = true;
1500-
}
1501-
1502-
if (!foundFileSpec) {
1503-
this._console.error(
1504-
`File or directory "${includeSpec.wildcardRoot.toUserVisibleString()}" does not exist.`
1505-
);
1506-
}
1507-
}
1508-
});
1509-
1510-
return results;
15111437
}
15121438

15131439
private _removeSourceFileWatchers() {

0 commit comments

Comments
 (0)