Skip to content
Draft
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
68 changes: 60 additions & 8 deletions internal/compiler/checkerpool.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ import (
"github.com/microsoft/typescript-go/internal/core"
)

// CheckerPool is implemented by the project system to provide checkers with
// request-scoped lifetime and reclamation. It returns a checker and a release
// function that must be called when the caller is done with the checker.
// The returned checker must not be accessed concurrently; each acquisition is exclusive.
// If file is non-nil, the pool may use it as an affinity hint to return the same
// checker for the same file across calls.
type CheckerPool interface {
GetChecker(ctx context.Context) (*checker.Checker, func())
GetCheckerForFile(ctx context.Context, file *ast.SourceFile) (*checker.Checker, func())
GetCheckerForFileExclusive(ctx context.Context, file *ast.SourceFile) (*checker.Checker, func())
GetChecker(ctx context.Context, file *ast.SourceFile) (*checker.Checker, func())
}

type checkerPool struct {
Expand Down Expand Up @@ -46,12 +50,29 @@ func newCheckerPool(program *Program) *checkerPool {
return pool
}

func (p *checkerPool) GetCheckerForFile(ctx context.Context, file *ast.SourceFile) (*checker.Checker, func()) {
// GetChecker implements CheckerPool. When file is non-nil, returns the checker
// associated with that file; otherwise returns the first checker.
func (p *checkerPool) GetChecker(ctx context.Context, file *ast.SourceFile) (*checker.Checker, func()) {
if file != nil {
return p.getCheckerForFileExclusive(ctx, file)
}
p.createCheckers()
c := p.checkers[0]
p.locks[0].Lock()
return c, sync.OnceFunc(func() {
p.locks[0].Unlock()
})
}

// getCheckerForFileNonExclusive returns the checker for the given file without locking.
// This is only safe when the caller guarantees no concurrent access to the same checker,
// e.g. for read-only operations like obtaining an emit resolver.
func (p *checkerPool) getCheckerForFileNonExclusive(file *ast.SourceFile) (*checker.Checker, func()) {
p.createCheckers()
return p.fileAssociations[file], noop
}

func (p *checkerPool) GetCheckerForFileExclusive(ctx context.Context, file *ast.SourceFile) (*checker.Checker, func()) {
func (p *checkerPool) getCheckerForFileExclusive(ctx context.Context, file *ast.SourceFile) (*checker.Checker, func()) {
p.createCheckers()
c := p.fileAssociations[file]
idx := slices.Index(p.checkers, c)
Expand All @@ -61,10 +82,10 @@ func (p *checkerPool) GetCheckerForFileExclusive(ctx context.Context, file *ast.
})
}

func (p *checkerPool) GetChecker(ctx context.Context) (*checker.Checker, func()) {
// getCheckerNonExclusive returns the first checker without locking.
func (p *checkerPool) getCheckerNonExclusive() (*checker.Checker, func()) {
p.createCheckers()
checker := p.checkers[0]
return checker, noop
return p.checkers[0], noop
}

func (p *checkerPool) createCheckers() {
Expand Down Expand Up @@ -101,4 +122,35 @@ func (p *checkerPool) forEachCheckerParallel(cb func(idx int, c *checker.Checker
wg.RunAndWait()
}

func (p *checkerPool) GetGlobalDiagnostics() []*ast.Diagnostic {
p.createCheckers()
globalDiagnostics := make([][]*ast.Diagnostic, len(p.checkers))
p.forEachCheckerParallel(func(idx int, checker *checker.Checker) {
globalDiagnostics[idx] = checker.GetGlobalDiagnostics()
})
return SortAndDeduplicateDiagnostics(slices.Concat(globalDiagnostics...))
}

// forEachCheckerGroupDo runs one task per checker in parallel. Each task iterates
// the provided files, processing only those assigned to its checker. Within each
// checker's set, files are visited in their original order.
func (p *checkerPool) forEachCheckerGroupDo(ctx context.Context, files []*ast.SourceFile, singleThreaded bool, cb func(c *checker.Checker, fileIndex int, file *ast.SourceFile)) {
p.createCheckers()

checkerCount := len(p.checkers)
wg := core.NewWorkGroup(singleThreaded)
for checkerIdx := range checkerCount {
wg.Queue(func() {
p.locks[checkerIdx].Lock()
defer p.locks[checkerIdx].Unlock()
for i, file := range files {
if checker := p.checkers[checkerIdx]; checker == p.fileAssociations[file] {
cb(checker, i, file)
}
}
})
}
wg.RunAndWait()
}

func noop() {}
146 changes: 89 additions & 57 deletions internal/compiler/program.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,12 @@ type packageNamesInfo struct {

type Program struct {
opts ProgramOptions
checkerPool CheckerPool
checkerPool CheckerPool // always set; used as fallback for project system pools

// compilerCheckerPool is set only when the built-in compiler checker pool is in use
// (i.e. CreateCheckerPool was not provided). It enables grouped parallel iteration,
// non-exclusive access for emit, and direct global diagnostics collection.
compilerCheckerPool *checkerPool

comparePathsOptions tspath.ComparePathsOptions

Expand Down Expand Up @@ -313,7 +318,9 @@ func (p *Program) initCheckerPool() {
if p.opts.CreateCheckerPool != nil {
p.checkerPool = p.opts.CreateCheckerPool(p)
} else {
p.checkerPool = newCheckerPool(p)
pool := newCheckerPool(p)
p.checkerPool = pool
p.compilerCheckerPool = pool
}
}

Expand Down Expand Up @@ -407,12 +414,15 @@ func (p *Program) BindSourceFiles() {

// Return the type checker associated with the program.
func (p *Program) GetTypeChecker(ctx context.Context) (*checker.Checker, func()) {
return p.checkerPool.GetChecker(ctx)
if p.compilerCheckerPool != nil {
return p.compilerCheckerPool.getCheckerNonExclusive()
}
return p.checkerPool.GetChecker(ctx, nil)
}

func (p *Program) ForEachCheckerParallel(cb func(idx int, c *checker.Checker)) {
if pool, ok := p.checkerPool.(*checkerPool); ok {
pool.forEachCheckerParallel(cb)
if p.compilerCheckerPool != nil {
p.compilerCheckerPool.forEachCheckerParallel(cb)
}
}

Expand All @@ -421,13 +431,19 @@ func (p *Program) ForEachCheckerParallel(cb func(idx int, c *checker.Checker)) {
// types obtained from different checkers, so only non-type data (such as diagnostics or string
// representations of types) should be obtained from checkers returned by this method.
func (p *Program) GetTypeCheckerForFile(ctx context.Context, file *ast.SourceFile) (*checker.Checker, func()) {
return p.checkerPool.GetCheckerForFile(ctx, file)
if p.compilerCheckerPool != nil {
return p.compilerCheckerPool.getCheckerForFileNonExclusive(file)
}
return p.checkerPool.GetChecker(ctx, file)
}

// Return a checker for the given file, locked to the current thread to prevent data races from multiple threads
// accessing the same checker. The lock will be released when the `done` function is called.
func (p *Program) GetTypeCheckerForFileExclusive(ctx context.Context, file *ast.SourceFile) (*checker.Checker, func()) {
return p.checkerPool.GetCheckerForFileExclusive(ctx, file)
if p.compilerCheckerPool != nil {
return p.compilerCheckerPool.getCheckerForFileExclusive(ctx, file)
}
return p.checkerPool.GetChecker(ctx, file)
}

func (p *Program) GetResolvedModule(file ast.HasFileName, moduleReference string, mode core.ResolutionMode) *module.ResolvedModule {
Expand Down Expand Up @@ -477,6 +493,48 @@ func (p *Program) collectDiagnosticsFromFiles(ctx context.Context, sourceFiles [
return diagnostics
}

// collectCheckerDiagnostics collects diagnostics from a single file or all files,
// using a callback that receives the checker for each file. When the checker pool
// supports grouped iteration (compiler pool), files are grouped by checker and
// processed in parallel with one task per checker, reducing contention and improving
// cache locality. Otherwise, falls back to per-file concurrent collection.
func (p *Program) collectCheckerDiagnostics(ctx context.Context, sourceFile *ast.SourceFile, collect func(context.Context, *checker.Checker, *ast.SourceFile) []*ast.Diagnostic) []*ast.Diagnostic {
if sourceFile != nil {
if p.SkipTypeChecking(sourceFile, false) {
return nil
}
c, done := p.GetTypeCheckerForFileExclusive(ctx, sourceFile)
result := collect(ctx, c, sourceFile)
done()
return SortAndDeduplicateDiagnostics(result)
}
return SortAndDeduplicateDiagnostics(slices.Concat(p.collectCheckerDiagnosticsFromFiles(ctx, p.files, collect)...))
}

// collectCheckerDiagnosticsFromFiles collects checker diagnostics for a list of files.
func (p *Program) collectCheckerDiagnosticsFromFiles(ctx context.Context, sourceFiles []*ast.SourceFile, collect func(context.Context, *checker.Checker, *ast.SourceFile) []*ast.Diagnostic) [][]*ast.Diagnostic {
diagnostics := make([][]*ast.Diagnostic, len(sourceFiles))
if p.compilerCheckerPool != nil {
p.compilerCheckerPool.forEachCheckerGroupDo(ctx, sourceFiles, p.SingleThreaded(), func(c *checker.Checker, fileIndex int, file *ast.SourceFile) {
diagnostics[fileIndex] = collect(ctx, c, file)
})
} else {
wg := core.NewWorkGroup(p.SingleThreaded())
for i, file := range sourceFiles {
if p.SkipTypeChecking(file, false) {
continue
}
wg.Queue(func() {
c, done := p.checkerPool.GetChecker(ctx, file)
diagnostics[i] = collect(ctx, c, file)
done()
})
}
wg.RunAndWait()
}
return diagnostics
}

func (p *Program) GetSyntacticDiagnostics(ctx context.Context, sourceFile *ast.SourceFile) []*ast.Diagnostic {
return p.collectDiagnostics(ctx, sourceFile, false /*concurrent*/, func(_ context.Context, file *ast.SourceFile) []*ast.Diagnostic {
diags := core.Concatenate(file.Diagnostics(), file.JSDiagnostics())
Expand Down Expand Up @@ -533,20 +591,20 @@ func (p *Program) GetBindDiagnostics(ctx context.Context, sourceFile *ast.Source
}

func (p *Program) GetSemanticDiagnostics(ctx context.Context, sourceFile *ast.SourceFile) []*ast.Diagnostic {
return p.collectDiagnostics(ctx, sourceFile, true /*concurrent*/, p.getSemanticDiagnosticsForFile)
return p.collectCheckerDiagnostics(ctx, sourceFile, p.getSemanticDiagnosticsWithChecker)
}

func (p *Program) GetSemanticDiagnosticsWithoutNoEmitFiltering(ctx context.Context, sourceFiles []*ast.SourceFile) map[*ast.SourceFile][]*ast.Diagnostic {
diagnostics := p.collectDiagnosticsFromFiles(ctx, sourceFiles, true /*concurrent*/, p.getBindAndCheckDiagnosticsForFile)
allDiags := p.collectCheckerDiagnosticsFromFiles(ctx, sourceFiles, p.getBindAndCheckDiagnosticsWithChecker)
result := make(map[*ast.SourceFile][]*ast.Diagnostic, len(sourceFiles))
for i, diags := range diagnostics {
for i, diags := range allDiags {
result[sourceFiles[i]] = SortAndDeduplicateDiagnostics(diags)
}
return result
}

func (p *Program) GetSuggestionDiagnostics(ctx context.Context, sourceFile *ast.SourceFile) []*ast.Diagnostic {
return p.collectDiagnostics(ctx, sourceFile, true /*concurrent*/, p.getSuggestionDiagnosticsForFile)
return p.collectCheckerDiagnostics(ctx, sourceFile, p.getSuggestionDiagnosticsWithChecker)
}

func (p *Program) GetProgramDiagnostics() []*ast.Diagnostic {
Expand Down Expand Up @@ -1168,32 +1226,18 @@ func (p *Program) GetGlobalDiagnostics(ctx context.Context) []*ast.Diagnostic {
if len(p.files) == 0 {
return nil
}

pool := p.checkerPool.(*checkerPool)

globalDiagnostics := make([][]*ast.Diagnostic, len(pool.checkers))
pool.forEachCheckerParallel(func(idx int, checker *checker.Checker) {
globalDiagnostics[idx] = checker.GetGlobalDiagnostics()
})

return SortAndDeduplicateDiagnostics(slices.Concat(globalDiagnostics...))
if p.compilerCheckerPool != nil {
return p.compilerCheckerPool.GetGlobalDiagnostics()
}
// For external pools (project system), global diagnostics are collected
// incrementally as checkers are used, not via a bulk query.
return nil
}

func (p *Program) GetDeclarationDiagnostics(ctx context.Context, sourceFile *ast.SourceFile) []*ast.Diagnostic {
return p.collectDiagnostics(ctx, sourceFile, true /*concurrent*/, p.getDeclarationDiagnosticsForFile)
}

func (p *Program) GetOptionsDiagnostics(ctx context.Context) []*ast.Diagnostic {
return SortAndDeduplicateDiagnostics(core.Concatenate(p.GetGlobalDiagnostics(ctx), p.getOptionsDiagnosticsOfConfigFile()))
}

func (p *Program) getOptionsDiagnosticsOfConfigFile() []*ast.Diagnostic {
if p.Options() == nil || p.Options().ConfigFilePath == "" {
return nil
}
return p.GetConfigFileParsingDiagnostics()
}

func FilterNoEmitSemanticDiagnostics(diagnostics []*ast.Diagnostic, options *core.CompilerOptions) []*ast.Diagnostic {
if !options.NoEmit.IsTrue() {
return diagnostics
Expand All @@ -1203,31 +1247,25 @@ func FilterNoEmitSemanticDiagnostics(diagnostics []*ast.Diagnostic, options *cor
})
}

func (p *Program) getSemanticDiagnosticsForFile(ctx context.Context, sourceFile *ast.SourceFile) []*ast.Diagnostic {
func (p *Program) getSemanticDiagnosticsWithChecker(ctx context.Context, c *checker.Checker, sourceFile *ast.SourceFile) []*ast.Diagnostic {
return core.Concatenate(
FilterNoEmitSemanticDiagnostics(p.getBindAndCheckDiagnosticsForFile(ctx, sourceFile), p.Options()),
FilterNoEmitSemanticDiagnostics(p.getBindAndCheckDiagnosticsWithChecker(ctx, c, sourceFile), p.Options()),
p.GetIncludeProcessorDiagnostics(sourceFile),
)
}

// getBindAndCheckDiagnosticsForFile gets semantic diagnostics for a single file,
// including bind diagnostics, checker diagnostics, and handling of @ts-ignore/@ts-expect-error directives.
func (p *Program) getBindAndCheckDiagnosticsForFile(ctx context.Context, sourceFile *ast.SourceFile) []*ast.Diagnostic {
// getBindAndCheckDiagnosticsWithChecker gets semantic diagnostics for a single file using a
// caller-provided checker, including bind diagnostics, checker diagnostics, and handling
// of @ts-ignore/@ts-expect-error directives.
func (p *Program) getBindAndCheckDiagnosticsWithChecker(ctx context.Context, fileChecker *checker.Checker, sourceFile *ast.SourceFile) []*ast.Diagnostic {
compilerOptions := p.Options()
if p.SkipTypeChecking(sourceFile, false) {
return nil
}

// IIFE to release checker as soon as possible.
diags := func() []*ast.Diagnostic {
fileChecker, done := p.checkerPool.GetCheckerForFileExclusive(ctx, sourceFile)
defer done()

// Getting a checker will force a bind, so this will be populated.
diags := slices.Clip(sourceFile.BindDiagnostics())
diags = append(diags, fileChecker.GetDiagnostics(ctx, sourceFile)...)
return diags
}()
// Checker creation forces binding, so bind diagnostics will be populated.
diags := slices.Clip(sourceFile.BindDiagnostics())
diags = append(diags, fileChecker.GetDiagnostics(ctx, sourceFile)...)

isPlainJS := ast.IsPlainJSFile(sourceFile, compilerOptions.CheckJs)
if isPlainJS {
Expand Down Expand Up @@ -1304,15 +1342,12 @@ func (p *Program) getDeclarationDiagnosticsForFile(ctx context.Context, sourceFi
return diagnostics
}

func (p *Program) getSuggestionDiagnosticsForFile(ctx context.Context, sourceFile *ast.SourceFile) []*ast.Diagnostic {
func (p *Program) getSuggestionDiagnosticsWithChecker(ctx context.Context, fileChecker *checker.Checker, sourceFile *ast.SourceFile) []*ast.Diagnostic {
if p.SkipTypeChecking(sourceFile, false) {
return nil
}

fileChecker, done := p.checkerPool.GetCheckerForFileExclusive(ctx, sourceFile)
defer done()

// Getting a checker will force a bind, so this will be populated.
// Checker creation forces binding, so bind suggestion diagnostics will be populated.
diags := slices.Clip(sourceFile.BindSuggestionDiagnostics)
diags = append(diags, fileChecker.GetSuggestionDiagnostics(ctx, sourceFile)...)

Expand Down Expand Up @@ -1591,7 +1626,6 @@ type ProgramLike interface {
GetConfigFileParsingDiagnostics() []*ast.Diagnostic
GetSyntacticDiagnostics(ctx context.Context, file *ast.SourceFile) []*ast.Diagnostic
GetBindDiagnostics(ctx context.Context, file *ast.SourceFile) []*ast.Diagnostic
GetOptionsDiagnostics(ctx context.Context) []*ast.Diagnostic
GetProgramDiagnostics() []*ast.Diagnostic
GetGlobalDiagnostics(ctx context.Context) []*ast.Diagnostic
GetSemanticDiagnostics(ctx context.Context, file *ast.SourceFile) []*ast.Diagnostic
Expand Down Expand Up @@ -1640,18 +1674,16 @@ func GetDiagnosticsOfAnyProgram(
allDiagnostics = append(allDiagnostics, program.GetProgramDiagnostics()...)

if len(allDiagnostics) == configFileParsingDiagnosticsLength {
// Options diagnostics include global diagnostics (even though we collect them separately),
// and global diagnostics create checkers, which then bind all of the files. Do this binding
// early so we can track the time.
// Do binding early so we can track the time.
getBindDiagnostics(ctx, file)

allDiagnostics = append(allDiagnostics, program.GetOptionsDiagnostics(ctx)...)

if program.Options().ListFilesOnly.IsFalseOrUnknown() {
allDiagnostics = append(allDiagnostics, program.GetGlobalDiagnostics(ctx)...)

if len(allDiagnostics) == configFileParsingDiagnosticsLength {
allDiagnostics = append(allDiagnostics, getSemanticDiagnostics(ctx, file)...)
// Ask for the global diagnostics again (they were empty above); we may have found new during checking, e.g. missing globals.
allDiagnostics = append(allDiagnostics, program.GetGlobalDiagnostics(ctx)...)
}

if (skipNoEmitCheckForDtsDiagnostics || program.Options().NoEmit.IsTrue()) && program.Options().GetEmitDeclarations() && len(allDiagnostics) == configFileParsingDiagnosticsLength {
Expand Down
7 changes: 0 additions & 7 deletions internal/execute/incremental/program.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,6 @@ func (p *Program) GetBindDiagnostics(ctx context.Context, file *ast.SourceFile)
return p.program.GetBindDiagnostics(ctx, file)
}

// GetOptionsDiagnostics implements compiler.AnyProgram interface.
func (p *Program) GetOptionsDiagnostics(ctx context.Context) []*ast.Diagnostic {
p.panicIfNoProgram("GetOptionsDiagnostics")
return p.program.GetOptionsDiagnostics(ctx)
}

func (p *Program) GetProgramDiagnostics() []*ast.Diagnostic {
p.panicIfNoProgram("GetProgramDiagnostics")
return p.program.GetProgramDiagnostics()
Expand Down Expand Up @@ -365,7 +359,6 @@ func (p *Program) ensureHasErrorsForState(ctx context.Context, program *compiler
len(program.GetConfigFileParsingDiagnostics()) > 0 ||
len(program.GetSyntacticDiagnostics(ctx, nil)) > 0 ||
len(program.GetProgramDiagnostics()) > 0 ||
len(program.GetOptionsDiagnostics(ctx)) > 0 ||
len(program.GetGlobalDiagnostics(ctx)) > 0 {
p.snapshot.hasErrors = core.TSTrue
// Dont need to encode semantic errors state since the syntax and program diagnostics are encoded as present
Expand Down
3 changes: 3 additions & 0 deletions internal/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,9 @@ func registerLanguageServiceDocumentRequestHandler[Req lsproto.HasTextDocumentUR
return func() error {
defer s.recover(req)
resp, lsErr := fn(s, ctx, ls, params)
// After any language service request, check if new global diagnostics were
// discovered during checking and push updated tsconfig diagnostics if so.
s.session.EnqueuePublishGlobalDiagnostics()
if lsErr != nil {
return lsErr
}
Expand Down
Loading
Loading