Skip to content

Handle delayed or missed watches for project updates #59625

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 3 commits into from
Aug 14, 2024
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
12 changes: 12 additions & 0 deletions src/compiler/watchUtilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
FileWatcherCallback,
FileWatcherEventKind,
find,
forEachAncestorDirectory,
getAllowJSCompilerOption,
getBaseFileName,
getDirectoryPath,
Expand Down Expand Up @@ -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) {
Expand All @@ -302,6 +310,7 @@ export function createCachedDirectoryStructureHost(host: DirectoryStructureHost,

const parentResult = getCachedFileSystemEntriesForBaseDir(fileOrDirectoryPath);
if (!parentResult) {
clearFirstAncestorEntry(fileOrDirectoryPath);
return undefined;
}

Expand Down Expand Up @@ -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 {
Expand Down
48 changes: 35 additions & 13 deletions src/server/editorServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
length,
map,
mapDefinedIterator,
memoize,
missingFileModifiedTime,
MultiMap,
noop,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
26 changes: 13 additions & 13 deletions src/testRunner/unittests/helpers/virtualFileSystemWithWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
}
}
Expand Down
88 changes: 88 additions & 0 deletions src/testRunner/unittests/tsserver/getEditsForFileRename.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
})
);
});
29 changes: 24 additions & 5 deletions src/testRunner/unittests/tsserver/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions tests/baselines/reference/api/typescript.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3318,6 +3318,7 @@ declare namespace ts {
setHostConfiguration(args: protocol.ConfigureRequestArguments): void;
private getWatchOptionsFromProjectWatchOptions;
closeLog(): void;
private sendSourceFileChange;
/**
* 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ export {}
//// [/user/username/projects/myproject/node_modules2/@types/qqq/index.d.ts] deleted

Output::
sysLog:: /user/username/projects/myproject/node_modules:: Changing watcher to PresentFileSystemEntryWatcher
sysLog:: /user/username/projects/myproject/node_modules/@types:: Changing watcher to PresentFileSystemEntryWatcher
sysLog:: /user/username/projects/myproject/node_modules:: Changing watcher to PresentFileSystemEntryWatcher


PolledWatches::
Expand Down Expand Up @@ -115,14 +115,13 @@ FsWatchesRecursive::
{}

Timeout callback:: count: 2
11: timerToUpdateProgram *new*
13: timerToInvalidateFailedLookupResolutions *new*
7: timerToUpdateProgram *new*
10: timerToInvalidateFailedLookupResolutions *new*

Before running Timeout callback:: count: 2
11: timerToUpdateProgram
13: timerToInvalidateFailedLookupResolutions
7: timerToUpdateProgram
10: timerToInvalidateFailedLookupResolutions

Host is moving to new time
After running Timeout callback:: count: 0
Output::
>> Screen clear
Expand Down Expand Up @@ -167,7 +166,7 @@ FsWatchesRecursive::
{}

Timeout callback:: count: 0
13: timerToInvalidateFailedLookupResolutions *deleted*
10: timerToInvalidateFailedLookupResolutions *deleted*


Program root files: [
Expand Down
Loading