Skip to content

Commit 2a2a665

Browse files
Cap how many projects a workspace pull checks at once
How many projects a pull checks concurrently was a constant. experimental.workspaceDiagnostics.maxProjects sets it; zero still derives a value from the available processors and single threaded mode still collapses it to one. The cap is budgeted against the per-project checker split rather than multiplied by it, so a project checked by four checkers does not also multiply how many projects run at once. Worth 2.7x on a single project, which is where checking projects concurrently had nothing to offer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 75237c0 commit 2a2a665

2 files changed

Lines changed: 53 additions & 14 deletions

File tree

tsc/internal/lsp/workspacediagnostics.go

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package lsp
22

33
import (
44
"context"
5+
"sync/atomic"
56
"time"
67

78
"github.com/microsoft/TypeScript/tsc/internal/ast"
@@ -168,7 +169,8 @@ func (r *workspaceDiagnosticsRun) collect(snapshot *project.Snapshot, scope lsut
168169
}
169170
r.beginProgress()
170171

171-
if concurrency := workspaceDiagnosticsConcurrency(work); concurrency > 1 {
172+
configured := snapshot.UserPreferences().WorkspaceDiagnosticsMaxProjects
173+
if concurrency := workspaceDiagnosticsConcurrency(snapshot, work, configured); concurrency > 1 {
172174
r.checkConcurrently(snapshot, work, concurrency)
173175
} else {
174176
r.checkSequentially(snapshot, work)
@@ -237,17 +239,42 @@ func (r *workspaceDiagnosticsRun) checkConcurrently(snapshot *project.Snapshot,
237239

238240
// checkProject fills in the reports for the files of one project, reporting whether it got through
239241
// them all. A cancelled project must not be emitted: its remaining reports are still zero values.
242+
//
243+
// Files are grouped by the diagnostics checker that owns them. A group shares a checker so it runs
244+
// in sequence, but the groups run together, which is what splitting a project's diagnostics across
245+
// checkers buys.
240246
func (r *workspaceDiagnosticsRun) checkProject(snapshot *project.Snapshot, pf workspaceDiagnosticsProject) bool {
247+
slots := make([]*ast.SourceFile, len(pf.files))
248+
byFile := make(map[*ast.SourceFile]int, len(pf.files))
241249
for j, file := range pf.files {
242-
if file == nil {
243-
continue
250+
if file != nil {
251+
slots[j] = file
252+
byFile[file] = j
244253
}
245-
if r.ctx.Err() != nil {
246-
return false
254+
}
255+
toCheck := make([]*ast.SourceFile, 0, pf.toCheck)
256+
for _, file := range slots {
257+
if file != nil {
258+
toCheck = append(toCheck, file)
247259
}
248-
pf.reports[j] = r.reportForFile(snapshot, pf.languageService, file)
249260
}
250-
return true
261+
262+
groups := snapshot.GroupFilesByDiagnosticsChecker(pf.project, toCheck)
263+
var cancelled atomic.Bool
264+
wg := core.NewWorkGroup(len(groups) == 1)
265+
for _, group := range groups {
266+
wg.Queue(func() {
267+
for _, file := range group {
268+
if r.ctx.Err() != nil {
269+
cancelled.Store(true)
270+
return
271+
}
272+
pf.reports[byFile[file]] = r.reportForFile(snapshot, pf.languageService, file)
273+
}
274+
})
275+
}
276+
wg.RunAndWait()
277+
return !cancelled.Load()
251278
}
252279

253280
// emitProject hands a finished project's reports to the client and remembers which program version

tsc/internal/lsp/workspacediagnosticsscope.go

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,32 @@ import (
1414
"github.com/microsoft/TypeScript/tsc/internal/tspath"
1515
)
1616

17-
// Each concurrent project holds its own diagnostics checker, so this bounds peak memory.
17+
// Each concurrent project holds its own diagnostics checkers, so this bounds peak memory.
1818
const workspaceDiagnosticsMaxProjects = 4
1919

20-
// workspaceDiagnosticsConcurrency returns how many projects to check at once, mirroring the default
21-
// the build orchestrator uses for --builders: four, or one under single threaded mode. Unlike a
22-
// build, a pull runs while the user is typing, so it also leaves half the processors for the
23-
// requests they are waiting on.
24-
func workspaceDiagnosticsConcurrency(work []workspaceDiagnosticsProject) int {
20+
// workspaceDiagnosticsConcurrency returns how many projects to check at once. Left to itself it
21+
// mirrors what the build orchestrator uses for --builders: four, or one under single threaded mode.
22+
// Unlike a build, a pull runs while the user is typing, so the default also leaves half the
23+
// processors for the requests they are waiting on.
24+
//
25+
// Every project being checked holds a checker per file group, and each of those holds the types for
26+
// its share of that project, so the default budget is spent across both caps rather than allowing
27+
// projects times checkers of them at once. A configured value is taken at its word.
28+
func workspaceDiagnosticsConcurrency(snapshot *project.Snapshot, work []workspaceDiagnosticsProject, configured int) int {
2529
for _, pf := range work {
2630
if pf.languageService.GetProgram().SingleThreaded() {
2731
return 1
2832
}
2933
}
30-
return min(len(work), workspaceDiagnosticsMaxProjects, max(1, runtime.GOMAXPROCS(0)/2))
34+
if configured > 0 {
35+
return min(len(work), configured)
36+
}
37+
budget := min(workspaceDiagnosticsMaxProjects, max(1, runtime.GOMAXPROCS(0)/2))
38+
perProject := 1
39+
for _, pf := range work {
40+
perProject = max(perProject, snapshot.DiagnosticCheckerCount(pf.project))
41+
}
42+
return min(len(work), max(1, budget/perProject))
3143
}
3244

3345
// projectsInScope narrows the loaded projects to the ones the scope reports on, in snapshot order.

0 commit comments

Comments
 (0)