Handle delayed or missed watches for project updates (#59625)

This commit is contained in:
Sheetal Nandi
2024-08-13 17:58:08 -07:00
committed by GitHub
parent 195203e971
commit 7753487591
13 changed files with 2202 additions and 246 deletions
+12
View File
@@ -21,6 +21,7 @@ import {
FileWatcherCallback,
FileWatcherEventKind,
find,
forEachAncestorDirectory,
getAllowJSCompilerOption,
getBaseFileName,
getDirectoryPath,
@@ -291,6 +292,13 @@ export function createCachedDirectoryStructureHost(host: DirectoryStructureHost,
return host.realpath ? host.realpath(s) : s;
}
function clearFirstAncestorEntry(fileOrDirectoryPath: Path) {
forEachAncestorDirectory(
getDirectoryPath(fileOrDirectoryPath),
ancestor => cachedReadDirectoryResult.delete(ensureTrailingDirectorySeparator(ancestor)) ? true : undefined,
);
}
function addOrDeleteFileOrDirectory(fileOrDirectory: string, fileOrDirectoryPath: Path) {
const existingResult = getCachedFileSystemEntries(fileOrDirectoryPath);
if (existingResult !== undefined) {
@@ -302,6 +310,7 @@ export function createCachedDirectoryStructureHost(host: DirectoryStructureHost,
const parentResult = getCachedFileSystemEntriesForBaseDir(fileOrDirectoryPath);
if (!parentResult) {
clearFirstAncestorEntry(fileOrDirectoryPath);
return undefined;
}
@@ -339,6 +348,9 @@ export function createCachedDirectoryStructureHost(host: DirectoryStructureHost,
if (parentResult) {
updateFilesOfFileSystemEntry(parentResult, getBaseNameOfFileName(fileName), eventKind === FileWatcherEventKind.Created);
}
else {
clearFirstAncestorEntry(filePath);
}
}
function updateFilesOfFileSystemEntry(parentResult: SortedAndCanonicalizedMutableFileSystemEntries, baseName: string, fileExists: boolean): void {
+35 -13
View File
@@ -80,6 +80,7 @@ import {
length,
map,
mapDefinedIterator,
memoize,
missingFileModifiedTime,
MultiMap,
noop,
@@ -1912,6 +1913,11 @@ export class ProjectService {
this.watchPackageJsonFile(file, fileOrDirectoryPath, wildCardWatcher);
}
if (!fsResult?.fileExists) {
// Ensure we send sourceFileChange
this.sendSourceFileChange(fileOrDirectoryPath);
}
const configuredProjectForConfig = this.findConfiguredProjectByProjectName(configFileName);
if (
isIgnoredFileFromWildCardWatching({
@@ -3838,6 +3844,34 @@ export class ProjectService {
this.logger.close();
}
private sendSourceFileChange(inPath: Path | undefined) {
this.filenameToScriptInfo.forEach(info => {
if (this.openFiles.has(info.path)) return; // Skip open files
if (!info.fileWatcher) return; // not watched file
const eventKind = memoize(() =>
this.host.fileExists(info.fileName) ?
info.deferredDelete ?
FileWatcherEventKind.Created :
FileWatcherEventKind.Changed :
FileWatcherEventKind.Deleted
);
if (inPath) {
// Skip node modules and files that are not in path
if (isScriptInfoWatchedFromNodeModules(info) || !info.path.startsWith(inPath)) return;
// If we are sending delete event and its already deleted, ignore
if (eventKind() === FileWatcherEventKind.Deleted && info.deferredDelete) return;
// In change cases, its hard to know if this is marked correctly across files and projects, so just send the event
this.logger.info(`Invoking sourceFileChange on ${info.fileName}:: ${eventKind()}`);
}
// Handle as if file is changed or deleted
this.onSourceFileChanged(
info,
eventKind(),
);
});
}
/**
* This function rebuilds the project for every file opened by the client
* This does not reload contents of open files from disk. But we could do that if needed
@@ -3850,19 +3884,7 @@ export class ProjectService {
// as there is no need to load contents of the files from the disk
// Reload script infos
this.filenameToScriptInfo.forEach(info => {
if (this.openFiles.has(info.path)) return; // Skip open files
if (!info.fileWatcher) return; // not watched file
// Handle as if file is changed or deleted
this.onSourceFileChanged(
info,
this.host.fileExists(info.fileName) ?
info.deferredDelete ?
FileWatcherEventKind.Created :
FileWatcherEventKind.Changed :
FileWatcherEventKind.Deleted,
);
});
this.sendSourceFileChange(/*inPath*/ undefined);
// Cancel all project updates since we will be updating them now
this.pendingProjectUpdates.forEach((_project, projectName) => {
this.throttledOperations.cancel(projectName);
@@ -556,33 +556,33 @@ export class TestServerHost implements server.ServerHost, FormatDiagnosticsHost,
this.addFileOrFolderInFolder(baseFolder, newFile);
}
renameFolder(folderName: string, newFolderName: string) {
renameFolder(folderName: string, newFolderName: string, skipFolderEntryWatches?: true) {
const fullPath = getNormalizedAbsolutePath(folderName, this.currentDirectory);
const path = this.toPath(fullPath);
const folder = this.fs.get(path) as FsFolder;
Debug.assert(!!folder);
const newFullPath = getNormalizedAbsolutePath(newFolderName, this.currentDirectory);
const newFolder = this.toFsFolder(newFullPath);
// Invoke watches for files in the folder as deleted (from old path)
this.renameFolderEntries(folder, newFolder, skipFolderEntryWatches);
// Only remove the folder
this.removeFileOrFolder(folder, /*isRenaming*/ true);
// Add updated folder with new folder name
const newFullPath = getNormalizedAbsolutePath(newFolderName, this.currentDirectory);
const newFolder = this.toFsFolder(newFullPath);
const newPath = newFolder.path;
const basePath = getDirectoryPath(path);
Debug.assert(basePath !== path);
Debug.assert(basePath === getDirectoryPath(newPath));
const basePath = getDirectoryPath(newPath);
this.ensureFileOrFolder({ path: getDirectoryPath(newFullPath) });
const baseFolder = this.fs.get(basePath) as FsFolder;
this.addFileOrFolderInFolder(baseFolder, newFolder);
// Invoke watches for files in the folder as deleted (from old path)
this.renameFolderEntries(folder, newFolder);
}
private renameFolderEntries(oldFolder: FsFolder, newFolder: FsFolder) {
private renameFolderEntries(oldFolder: FsFolder, newFolder: FsFolder, skipWatches: true | undefined) {
for (const entry of oldFolder.entries) {
this.fs.delete(entry.path);
this.invokeFileAndFsWatches(entry.fullPath, FileWatcherEventKind.Deleted, entry.fullPath);
if (!skipWatches) this.invokeFileAndFsWatches(entry.fullPath, FileWatcherEventKind.Deleted, entry.fullPath);
entry.fullPath = combinePaths(newFolder.fullPath, getBaseFileName(entry.fullPath));
entry.path = this.toPath(entry.fullPath);
@@ -591,9 +591,9 @@ export class TestServerHost implements server.ServerHost, FormatDiagnosticsHost,
}
this.fs.set(entry.path, entry);
this.setInode(entry.path);
this.invokeFileAndFsWatches(entry.fullPath, FileWatcherEventKind.Created, entry.fullPath);
if (!skipWatches) this.invokeFileAndFsWatches(entry.fullPath, FileWatcherEventKind.Created, entry.fullPath);
if (isFsFolder(entry)) {
this.renameFolderEntries(entry, entry);
this.renameFolderEntries(entry, entry, skipWatches);
}
}
}
@@ -1,9 +1,12 @@
import * as ts from "../../_namespaces/ts.js";
import { dedent } from "../../_namespaces/Utils.js";
import { jsonToReadableText } from "../helpers.js";
import { libContent } from "../helpers/contents.js";
import {
baselineTsserverLogs,
closeFilesForSession,
openFilesForSession,
protocolTextSpanFromSubstring,
TestSession,
textSpanFromSubstring,
verifyGetErrRequest,
@@ -207,4 +210,89 @@ describe("unittests:: tsserver:: getEditsForFileRename", () => {
});
})
);
[true, false].forEach(canUseWatchEvents =>
it(`works when moving file to and from folder${canUseWatchEvents ? " canUseWatchEvents" : ""}`, () => {
const alertText = dedent`
export function alert(message: string) {
console.log(\`ALERT: \${message}\`);
}
`;
const projectRootPath = "/home/src/myprojects/project";
const indexTs = `${projectRootPath}/index.ts`;
const indexFileText = dedent`
import { alert } from '@app/components/whatever/alert';
alert('Hello, world!');
`;
const componentsWhatever = `${projectRootPath}/components/whatever`;
const functionsWhatever = `${projectRootPath}/functions/whatever`;
const host = createServerHost({
"/home/src/myprojects/project/tsconfig.json": jsonToReadableText({
compilerOptions: {
target: "es2016",
module: "commonjs",
paths: {
"@app/*": [
"./*",
],
},
esModuleInterop: true,
forceConsistentCasingInFileNames: true,
strict: true,
skipLibCheck: true,
},
}),
[indexTs]: indexFileText,
[`${componentsWhatever}/alert.ts`]: alertText,
[`${functionsWhatever}/placeholder.txt`]: "",
"/a/lib/lib.es2016.full.d.ts": libContent,
});
const session = new TestSession({ host, canUseWatchEvents, canUseEvents: true });
openFilesForSession([{ file: indexTs, projectRootPath }], session);
host.renameFolder(componentsWhatever, functionsWhatever, /*skipFolderEntryWatches*/ true);
if (canUseWatchEvents) session.invokeWatchChanges();
openFilesForSession([{
file: `${functionsWhatever}/alert.ts`,
content: alertText,
projectRootPath,
}], session);
session.executeCommandSeq<ts.server.protocol.GetEditsForFileRenameRequest>({
command: ts.server.protocol.CommandTypes.GetEditsForFileRename,
arguments: { oldFilePath: componentsWhatever, newFilePath: functionsWhatever },
});
// Apply edit to index.ts
session.executeCommandSeq<ts.server.protocol.UpdateOpenRequest>({
command: ts.server.protocol.CommandTypes.UpdateOpen,
arguments: {
changedFiles: [{
fileName: indexTs,
textChanges: [{
...protocolTextSpanFromSubstring(indexFileText, "@app/components/whatever/alert"),
newText: "@app/functions/whatever/alert",
}],
}],
},
});
host.runQueuedTimeoutCallbacks();
host.renameFolder(functionsWhatever, componentsWhatever, /*skipFolderEntryWatches*/ true);
session.executeCommandSeq<ts.server.protocol.UpdateOpenRequest>({
command: ts.server.protocol.CommandTypes.UpdateOpen,
arguments: {
openFiles: [{
file: `${componentsWhatever}/alert.ts`,
fileContent: alertText,
projectRootPath,
}],
closedFiles: [`${functionsWhatever}/alert.ts`],
},
});
session.executeCommandSeq<ts.server.protocol.GetEditsForFileRenameRequest>({
command: ts.server.protocol.CommandTypes.GetEditsForFileRename,
arguments: { oldFilePath: functionsWhatever, newFilePath: componentsWhatever },
});
if (canUseWatchEvents) session.invokeWatchChanges();
host.runQueuedTimeoutCallbacks();
baselineTsserverLogs("getEditsForFileRename", `works when moving file to and from folder${canUseWatchEvents ? " canUseWatchEvents" : ""}`, session);
})
);
});
+24 -5
View File
@@ -1375,19 +1375,38 @@ describe("unittests:: tsserver:: projects::", () => {
host.runQueuedTimeoutCallbacks();
// file is deleted but watches are not yet invoked
const originalFileExists = host.fileExists;
host.fileExists = s => s === fileA.path ? false : originalFileExists.call(host, s);
const invokeFileWatcher = host.invokeFileWatcher;
const fileWatches: Parameters<typeof host.invokeFileWatcher>[] = [];
host.invokeFileWatcher = (fileFullPath, eventKind, modifiedTime) => {
fileWatches.push([fileFullPath, eventKind, modifiedTime]);
};
const invokeFsWatchesCallbacks = host.invokeFsWatchesCallbacks;
const fsWatches: Parameters<typeof host.invokeFsWatchesCallbacks>[] = [];
host.invokeFsWatchesCallbacks = (fullPath, eventName, eventFullPath, useTildeSuffix) => {
fsWatches.push([fullPath, eventName, eventFullPath, useTildeSuffix]);
};
const invokeFsWatchesRecursiveCallbacks = host.invokeFsWatchesRecursiveCallbacks;
const fsWatchesRecursive: Parameters<typeof host.invokeFsWatchesRecursiveCallbacks>[] = [];
host.invokeFsWatchesRecursiveCallbacks = (fullPath, eventName, eventFullPath, useTildeSuffix) => {
fsWatchesRecursive.push([fullPath, eventName, eventFullPath, useTildeSuffix]);
};
host.deleteFile(fileA.path);
host.ensureFileOrFolder(fileSubA);
closeFilesForSession([fileA], session);
// This should create inferred project since fileSubA not on the disk
openFile(fileSubA);
host.runQueuedTimeoutCallbacks(); // Update configured project and projects for open file
host.fileExists = originalFileExists;
host.invokeFileWatcher = invokeFileWatcher;
host.invokeFsWatchesCallbacks = invokeFsWatchesCallbacks;
host.invokeFsWatchesRecursiveCallbacks = invokeFsWatchesRecursiveCallbacks;
// Actually trigger the file move
host.deleteFile(fileA.path);
host.ensureFileOrFolder(fileSubA);
fileWatches.forEach(args => host.invokeFileWatcher(...args));
fsWatches.forEach(args => host.invokeFsWatchesCallbacks(...args));
fsWatchesRecursive.forEach(args => host.invokeFsWatchesRecursiveCallbacks(...args));
verifyGetErrRequest({ session, files: [fileB, fileSubA], existingTimeouts: true });
baselineTsserverLogs("projects", "handles delayed directory watch invoke on file creation", session);