-
Notifications
You must be signed in to change notification settings - Fork 13k
Description
🔎 Search Terms
"tsserver", "cache invalidation", "new file", "case sensitive", "IntelliSense", "inferred project", "mac os", "watch", "tsserver restart"
🕗 Version & Regression Information
This bug is reproducible between TypeScript 5.1.6 and 5.3.0-nightly (commit b555727).
I can test versions prior to 5.1.6 if necessary.
⏯ Playground Link
This bug is specific to a local editing environment and file system quirks. A playground would not be able reproduce this.
🪜 Steps to Reproduce
A few developers on my team have noticed that IntelliSense operations intermittently do not work in newly created files in VS Code. IntelliSense works in the new file after restarting TSServer.
Breakpoint debugging TSServer, I believe there's a file system caching bug related to case sensitivity. The bug can be reproduced consistently on macOS.
- Open a directory in VS Code with a
tsconfig.json
file on a case-sensitive volume. - Ensure the TypeScript version is set to "Use VS Code's Version". The problem only reproduces when
tsserver.js
is on a case-insensitive volume, which is usually true for/Applications/Visual Studio Code.app/Contents/Resources/app/extensions/node_modules/typescript/lib/tsserver.js
. - Create a new file with at least one capital letter in its name. (e.g.
Foo.tsx
). - Observe that IntelliSense operations performed within the new
Foo.tsx
file do not have information on other files of the sametsconfig.json
program.
🙁 Actual behavior
I've created a small repo to help reproduce the bug. There isn't anything special about the repo. It's more where the repo is cloned to and the file system case sensitivity that matters. https://github.com/gluxon/tsserver-new-file-intellisense-bug
Here's a video of the bug.
- The newly created
bar.ts
file is able to see IntelliSense information from the rest of the project. - The newly created
Baz.ts
file is not.
bug.mov
Looking over the TSServer logs for the video above, we can see that bar.ts
is matched with the right project. The Baz.ts
file is instead mapped to /dev/null/inferredProject2*
.
Info 743 [15:52:29.973] Open files:
Info 743 [15:52:29.973] FileName: /Volumes/Code/tsserver-new-file-intellisense-bug/foo.ts ProjectRootPath: /Volumes/Code/tsserver-new-file-intellisense-bug
Info 743 [15:52:29.973] Projects: /Volumes/Code/tsserver-new-file-intellisense-bug/tsconfig.json
Info 743 [15:52:29.973] FileName: /Volumes/Code/tsserver-new-file-intellisense-bug/bar.ts ProjectRootPath: /Volumes/Code/tsserver-new-file-intellisense-bug
Info 743 [15:52:29.973] Projects: /Volumes/Code/tsserver-new-file-intellisense-bug/tsconfig.json
Info 743 [15:52:29.973] FileName: /Volumes/Code/tsserver-new-file-intellisense-bug/Baz.ts ProjectRootPath: /Volumes/Code/tsserver-new-file-intellisense-bug
Info 743 [15:52:29.973] Projects: /dev/null/inferredProject2*
🙂 Expected behavior
TSServer always associates new files with the tsconfig.json
file that includes it instead of an inferred project.
🔬 Self-Debugging
We noticed that this problem does not reproduce when tsserver.js
is loaded from the same case-sensitive file system. It only happens when tsserver.js
is loaded from a case-insensitive file system. The main way of changing where tsserver.js
was loaded from was through typescript.tsdk
in .vscode/settings.json
.
With that toggle, we were able to reliably control whether or not the problem reproduces and compare TSServer's internal state for the successful and failing cases. Skip to the bottom for the conclusion.
Program Loading and Caching
Starting at a high-level, we've confirmed in a debugger a few important pieces of information:
- The TypeScript
Program
object'srootFilesMap
records what files are part of atsconfig.json
. The new file is not added to the expected program'srootFilesMap
in the failing case, but is added in the successful case. It makes sense why TSServer associates the newly created file to an inferred project given this. - The
rootFiles
androotFilesMap
fields are computed throughgetFileNamesFromConfigSpecs(...)
. This function does not return the newly created file in the failing case (but does in the successful case). - The
getFileNamesFromConfigSpecs
function callshost.readDirectory(...)
. ThisreadDirectory
call does not return the newly created file in the failing case, but does in the successful case.
The host
object in getFileNamesFromConfigSpecs
is an instance of the CachedDirectoryStructureHost
Debugging CachedDirectoryStructureHost
.
The CachedDirectoryStructureHost
object is kept up to date through addOrDeleteFileOrDirectory
in src/compiler/watchUtilities.ts
. We believe the fundamental difference between the failing and successful cases is the evaluation of this line
TypeScript/src/compiler/watchUtilities.ts
Line 315 in 913f65c
fileExists: host.fileExists(fileOrDirectoryPath), |
Failing Case
- The
fileOrDirectoryPath
const evaluates to/volumes/code/tsserver-new-file-intellisense-bug/baz.ts
fsQueryResult.fileExists
subsequently becomesfalse
.
Note that the entire fileOrDirectoryPath
string has been been shifted to be lowercase.
Successful Case
- The
fileOrDirectoryPath
const evaluates to/Volumes/Code/tsserver-new-file-intellisense-bug/Baz.ts
fsQueryResult.fileExists
subsequently becomestrue
.
In this scenario, the casing of the new file is preserved.
Why is fsQueryResult.fileExists
important?
The value of fsQueryResult.fileExists
is passed to updateFilesOfFileSystemEntry
.
TypeScript/src/compiler/watchUtilities.ts
Line 324 in a0e0104
updateFilesOfFileSystemEntry(parentResult, baseName, fsQueryResult.fileExists); |
Once updateFilesOfFileSystemEntry
runs, this changes whether or not the CachedDirectoryStructureHost
sees the newly created file. I'm able to verify this with a breakpoint on this line, and evaluating the results of readDirectory
in the debug console.
TypeScript/src/compiler/watchUtilities.ts
Line 326 in a0e0104
return fsQueryResult; |
In the successful case
❯ this.readDirectory("/Volumes/Code/tsserver-new-file-intellisense-bug").includes("/Volumes/Code/tsserver-new-file-intellisense-bug/Baz.ts")
false
// ...updateFilesOfFileSystemEntry(...)
❯ this.readDirectory("/Volumes/Code/tsserver-new-file-intellisense-bug").includes("/Volumes/Code/tsserver-new-file-intellisense-bug/Baz.ts")
true
In the failing case, this always evaluates to false
, even after the updateFilesOfFileSystemEntry
call.
❯ this.readDirectory("/Volumes/Code/tsserver-new-file-intellisense-bug").includes("/Volumes/Code/tsserver-new-file-intellisense-bug/Baz.ts")
false
Ultimately, I think the problem is before the call to updateFilesOfFileSystemEntry
problem though. I would guess updateFilesOfFileSystemEntry
should be passed a value of fsQueryResult.fileExists
that's true
regardless of file system case sensitivity.
How does TypeScript decide when to lowercase?
The fileOrDirectoryPath
may or may not be lowercased through:
TypeScript/src/server/editorServices.ts
Line 1505 in a0e0104
const fileOrDirectoryPath = this.toPath(fileOrDirectory); |
TypeScript/src/server/editorServices.ts
Lines 1066 to 1068 in a0e0104
toPath(fileName: string) { | |
return toPath(fileName, this.currentDirectory, this.toCanonicalFileName); | |
} |
The this.toCanonicalFileName
function performs lower-casing based on this.host.useCaseSensitiveFileNames
.
TypeScript/src/compiler/core.ts
Line 2604 in a0e0104
export function createGetCanonicalFileName(useCaseSensitiveFileNames: boolean): GetCanonicalFileName { |
TypeScript/src/server/editorServices.ts
Line 1028 in a0e0104
this.toCanonicalFileName = createGetCanonicalFileName(this.host.useCaseSensitiveFileNames); |
There's likely an assumption to unwind related to this.host.useCaseSensitiveFileNames
. I'm not sure if this is the TypeScript team's preferred fix, but I suspect we need to compute a different toCanonicalFileName
function per-project or per-watched folder since the case sensitivity of an opened project folder is independent to where tsserver.js
loads from.
Conclusion
The CachedDirectoryStructureHost
has a bug where it does not update its cachedReadDirectoryResult
internal variable if (1) newly created files have a capital letter and (2) tsserver.js
is spawned from a file system volume that's case-insensitive.