Skip to content

Introduce fast path for program updates in language service #879

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 4 commits into from
May 20, 2025
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
82 changes: 77 additions & 5 deletions internal/compiler/program.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package compiler

import (
"context"
"maps"
"slices"
"sync"

Expand Down Expand Up @@ -78,11 +79,7 @@ func NewProgram(options ProgramOptions) *Program {
if p.compilerOptions == nil {
p.compilerOptions = &core.CompilerOptions{}
}
if p.programOptions.CreateCheckerPool != nil {
p.checkerPool = p.programOptions.CreateCheckerPool(p)
} else {
p.checkerPool = newCheckerPool(core.IfElse(p.singleThreaded(), 1, 4), p)
}
p.initCheckerPool()

// p.maxNodeModuleJsDepth = p.options.MaxNodeModuleJsDepth

Expand Down Expand Up @@ -174,6 +171,81 @@ func NewProgram(options ProgramOptions) *Program {
return p
}

// Return an updated program for which it is known that only the file with the given path has changed.
// In addition to a new program, return a boolean indicating whether the data of the old program was reused.
func (p *Program) UpdateProgram(changedFilePath tspath.Path) (*Program, bool) {
oldFile := p.filesByPath[changedFilePath]
newFile := p.host.GetSourceFile(oldFile.FileName(), changedFilePath, oldFile.LanguageVersion)
if !canReplaceFileInProgram(oldFile, newFile) {
return NewProgram(p.programOptions), false
}
result := &Program{
host: p.host,
programOptions: p.programOptions,
compilerOptions: p.compilerOptions,
configFileName: p.configFileName,
nodeModules: p.nodeModules,
currentDirectory: p.currentDirectory,
configFileParsingDiagnostics: p.configFileParsingDiagnostics,
resolver: p.resolver,
comparePathsOptions: p.comparePathsOptions,
processedFiles: p.processedFiles,
filesByPath: p.filesByPath,
currentNodeModulesDepth: p.currentNodeModulesDepth,
usesUriStyleNodeCoreModules: p.usesUriStyleNodeCoreModules,
unsupportedExtensions: p.unsupportedExtensions,
}
result.initCheckerPool()
index := core.FindIndex(result.files, func(file *ast.SourceFile) bool { return file.Path() == newFile.Path() })
result.files = slices.Clone(result.files)
result.files[index] = newFile
result.filesByPath = maps.Clone(result.filesByPath)
result.filesByPath[newFile.Path()] = newFile
return result, true
}

func (p *Program) initCheckerPool() {
if p.programOptions.CreateCheckerPool != nil {
p.checkerPool = p.programOptions.CreateCheckerPool(p)
} else {
p.checkerPool = newCheckerPool(core.IfElse(p.singleThreaded(), 1, 4), p)
}
}

func canReplaceFileInProgram(file1 *ast.SourceFile, file2 *ast.SourceFile) bool {
return file1.FileName() == file2.FileName() &&
file1.Path() == file2.Path() &&
file1.LanguageVersion == file2.LanguageVersion &&
file1.LanguageVariant == file2.LanguageVariant &&
file1.ScriptKind == file2.ScriptKind &&
file1.IsDeclarationFile == file2.IsDeclarationFile &&
file1.HasNoDefaultLib == file2.HasNoDefaultLib &&
file1.UsesUriStyleNodeCoreModules == file2.UsesUriStyleNodeCoreModules &&
slices.EqualFunc(file1.Imports, file2.Imports, equalModuleSpecifiers) &&
slices.EqualFunc(file1.ModuleAugmentations, file2.ModuleAugmentations, equalModuleAugmentationNames) &&
slices.Equal(file1.AmbientModuleNames, file2.AmbientModuleNames) &&
slices.EqualFunc(file1.ReferencedFiles, file2.ReferencedFiles, equalFileReferences) &&
slices.EqualFunc(file1.TypeReferenceDirectives, file2.TypeReferenceDirectives, equalFileReferences) &&
slices.EqualFunc(file1.LibReferenceDirectives, file2.LibReferenceDirectives, equalFileReferences) &&
equalCheckJSDirectives(file1.CheckJsDirective, file2.CheckJsDirective)
}

func equalModuleSpecifiers(n1 *ast.Node, n2 *ast.Node) bool {
return n1.Kind == n2.Kind && (!ast.IsStringLiteral(n1) || n1.Text() == n2.Text())
}

func equalModuleAugmentationNames(n1 *ast.Node, n2 *ast.Node) bool {
return n1.Kind == n2.Kind && n1.Text() == n2.Text()
}

func equalFileReferences(f1 *ast.FileReference, f2 *ast.FileReference) bool {
return f1.FileName == f2.FileName && f1.ResolutionMode == f2.ResolutionMode && f1.Preserve == f2.Preserve
}

func equalCheckJSDirectives(d1 *ast.CheckJsDirective, d2 *ast.CheckJsDirective) bool {
return d1 == nil && d2 == nil || d1 != nil && d2 != nil && d1.Enabled == d2.Enabled
}

func NewProgramFromParsedCommandLine(config *tsoptions.ParsedCommandLine, host CompilerHost) *Program {
programOptions := ProgramOptions{
RootFiles: config.FileNames(),
Expand Down
67 changes: 42 additions & 25 deletions internal/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ type Project struct {
hasAddedOrRemovedSymlinks bool
deferredClose bool
pendingReload PendingReload
dirtyFilePath tspath.Path

comparePathsOptions tspath.ComparePathsOptions
currentDirectory string
Expand Down Expand Up @@ -372,14 +373,23 @@ func (p *Project) getScriptKind(fileName string) core.ScriptKind {
}

func (p *Project) markFileAsDirty(path tspath.Path) {
p.markAsDirty()
p.dirtyStateMu.Lock()
defer p.dirtyStateMu.Unlock()
if !p.dirty {
p.dirty = true
p.dirtyFilePath = path
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this may need to be a Set or something; file watching can cause this to be overwritten with subsequent updates, or even open files on branch switches or similar.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, but the minute anything else changes, we clear out p.dirtyFilePath and updateGraph will then completely re-create the program.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see what you mean in updateProgram; you're right that it's safe. I guess we don't need to be super optimal (though I think it might just be a couple lines to keep track of multiple dirty files).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this temporary? because we have this https://github.com/microsoft/TypeScript/blob/main/src/compiler/program.ts#L2458 in strada thats much more resilient to multiple file changes (can happen as part of open and close when navigating source code)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But as a trade-off, typing performance in Strada is extremely poor compared to what it could be. We definitely need new optimizations for the 99 percentile case of typing in a single file. Program construction shouldn’t have to loop over every source file to determine what’s changed when the project infrastructure already knows what changed.

p.version++
} else if path != p.dirtyFilePath {
p.dirtyFilePath = ""
}
}

func (p *Project) markAsDirty() {
p.dirtyStateMu.Lock()
defer p.dirtyStateMu.Unlock()
if !p.dirty {
p.dirty = true
p.dirtyFilePath = ""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to reset p.dirtyFilePath unconditionally here. Suppose we call p.markFileAsDirty() and it sets p.dirtyFilePath to some path, and then right after we call p.markAsDirty(): we won't reset p.dirtyFilePath because p.dirty is already true.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!

p.version++
}
}
Expand Down Expand Up @@ -430,47 +440,54 @@ func (p *Project) updateGraph() bool {

p.hasAddedOrRemovedFiles = false
p.hasAddedOrRemovedSymlinks = false
p.updateProgram()
oldProgramReused := p.updateProgram()
p.dirty = false
p.dirtyFilePath = ""
p.log(fmt.Sprintf("Finishing updateGraph: Project: %s version: %d", p.name, p.version))
if hasAddedOrRemovedFiles {
p.log(p.print(true /*writeFileNames*/, true /*writeFileExplanation*/, false /*writeFileVersionAndText*/))
} else if p.program != oldProgram {
p.log("Different program with same set of files")
}

if p.program != oldProgram && oldProgram != nil {
for _, oldSourceFile := range oldProgram.GetSourceFiles() {
if p.program.GetSourceFileByPath(oldSourceFile.Path()) == nil {
p.host.DocumentRegistry().ReleaseDocument(oldSourceFile, oldProgram.GetCompilerOptions())
if !oldProgramReused {
if oldProgram != nil {
for _, oldSourceFile := range oldProgram.GetSourceFiles() {
if p.program.GetSourceFileByPath(oldSourceFile.Path()) == nil {
p.host.DocumentRegistry().ReleaseDocument(oldSourceFile, oldProgram.GetCompilerOptions())
}
}
}
// TODO: this is currently always synchronously called by some kind of updating request,
// but in Strada we throttle, so at least sometimes this should be considered top-level?
p.updateWatchers(context.TODO())
}

// TODO: this is currently always synchronously called by some kind of updating request,
// but in Strada we throttle, so at least sometimes this should be considered top-level?
p.updateWatchers(context.TODO())
return true
}

func (p *Project) updateProgram() {
rootFileNames := p.GetRootFileNames()
compilerOptions := p.compilerOptions

func (p *Project) updateProgram() bool {
if p.checkerPool != nil {
p.logf("Program %d used %d checker(s)", p.version, p.checkerPool.size())
}
p.program = compiler.NewProgram(compiler.ProgramOptions{
RootFiles: rootFileNames,
Host: p,
Options: compilerOptions,
CreateCheckerPool: func(program *compiler.Program) compiler.CheckerPool {
p.checkerPool = newCheckerPool(4, program, p.log)
return p.checkerPool
},
})

var oldProgramReused bool
if p.program == nil || p.dirtyFilePath == "" {
rootFileNames := p.GetRootFileNames()
compilerOptions := p.compilerOptions
p.program = compiler.NewProgram(compiler.ProgramOptions{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we somehow propagate out whether the old program was cloned or not, we can use that to skip reevaluating the file watchers at the end of updateGraph too.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now in my latest commit.

RootFiles: rootFileNames,
Host: p,
Options: compilerOptions,
CreateCheckerPool: func(program *compiler.Program) compiler.CheckerPool {
p.checkerPool = newCheckerPool(4, program, p.log)
return p.checkerPool
},
})
} else {
// The only change in the current program is the contents of the file named by p.dirtyFilePath.
// If possible, use data from the old program to create the new program.
p.program, oldProgramReused = p.program.UpdateProgram(p.dirtyFilePath)
}
p.program.BindSourceFiles()
return oldProgramReused
}

func (p *Project) isOrphan() bool {
Expand Down