Skip to content

Commit 052306b

Browse files
committed
feat(configfileregistry): optimize config discovery
Refactor hot paths in project setup for better performance and simplicity. - Config acquisition and parsing: Add fast-path for already-parsed configs, deduplicate concurrent parses using a wait group, use narrower locks (read locks for reads, write locks only for updates), avoid full reloads on entry creation, and support targeted reloads (file lists only). - Default project discovery: Replace recursive goroutine-based traversal with a single-threaded iterative BFS queue. Track visited paths by (path, loadKind) pair, upgrade loadKind monotonically, and exit early on a good match. This removes recursion depth issues, goroutine overload, and sync overhead, while making resolution deterministic. - Result tracking and service wiring: Use a single mutex with plain maps instead of SyncMaps and multiple locks. Ensure monotonic updates to prevent revisits with weaker loadKind. Initialize activeParsing in NewService and make cleanup consistent with BFS using plain maps under mutex. These changes eliminate duplicate parsing, cut CPU spikes, and avoid goroutine storms in large reference graphs. The design now scales linearly with project size and offers more predictable latency under load. Performance: Old design: O(E) goroutines (E = edges in tsconfig graph), with duplicate work on converging paths. Parsing was O(N * c) worst-case (N = configs, c = concurrent requests per config). New design: O(V + E) traversal (V = tsconfigs), visiting each config once per strongest loadKind. Parsing is O(N) amortized, as each config parses only once. See oxc-project/tsgolint#99 for before/after analysis.
1 parent 28fe3d4 commit 052306b

File tree

3 files changed

+207
-76
lines changed

3 files changed

+207
-76
lines changed

internal/project/configfileregistry.go

Lines changed: 96 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,19 @@ type ExtendedConfigFileEntry struct {
2828
configFiles collections.Set[tspath.Path]
2929
}
3030

31+
type parseStatus struct {
32+
wg *sync.WaitGroup
33+
}
34+
3135
type ConfigFileRegistry struct {
3236
Host ProjectHost
3337
defaultProjectFinder *defaultProjectFinder
3438
ConfigFiles collections.SyncMap[tspath.Path, *ConfigFileEntry]
3539
ExtendedConfigCache collections.SyncMap[tspath.Path, *tsoptions.ExtendedConfigCacheEntry]
3640
ExtendedConfigsUsedBy collections.SyncMap[tspath.Path, *ExtendedConfigFileEntry]
41+
42+
parseMu sync.Mutex
43+
activeParsing map[tspath.Path]*parseStatus
3744
}
3845

3946
func (e *ConfigFileEntry) SetPendingReload(level PendingReload) bool {
@@ -74,41 +81,99 @@ func (c *ConfigFileRegistry) releaseConfig(path tspath.Path, project *Project) {
7481
}
7582

7683
func (c *ConfigFileRegistry) acquireConfig(fileName string, path tspath.Path, project *Project, info *ScriptInfo) *tsoptions.ParsedCommandLine {
77-
entry, ok := c.ConfigFiles.Load(path)
78-
if !ok {
79-
// Create parsed command line
84+
if entry, ok := c.ConfigFiles.Load(path); ok {
85+
entry.mu.RLock()
86+
needsReload := entry.pendingReload != PendingReloadNone
87+
commandLine := entry.commandLine
88+
entry.mu.RUnlock()
89+
90+
if !needsReload && commandLine != nil {
91+
entry.mu.Lock()
92+
if project != nil {
93+
entry.projects.Add(project)
94+
} else if info != nil {
95+
entry.infos.Add(info)
96+
}
97+
entry.mu.Unlock()
98+
return commandLine
99+
}
100+
}
101+
102+
c.parseMu.Lock()
103+
if status, ok := c.activeParsing[path]; ok {
104+
c.parseMu.Unlock()
105+
status.wg.Wait()
106+
107+
if entry, ok := c.ConfigFiles.Load(path); ok {
108+
entry.mu.Lock()
109+
defer entry.mu.Unlock()
110+
if project != nil {
111+
entry.projects.Add(project)
112+
} else if info != nil {
113+
entry.infos.Add(info)
114+
}
115+
return entry.commandLine
116+
}
117+
}
118+
119+
status := &parseStatus{wg: &sync.WaitGroup{}}
120+
status.wg.Add(1)
121+
c.activeParsing[path] = status
122+
c.parseMu.Unlock()
123+
124+
defer func() {
125+
status.wg.Done()
126+
c.parseMu.Lock()
127+
delete(c.activeParsing, path)
128+
c.parseMu.Unlock()
129+
}()
130+
131+
entry, loaded := c.ConfigFiles.Load(path)
132+
if !loaded {
80133
config, _ := tsoptions.GetParsedCommandLineOfConfigFilePath(fileName, path, nil, c.Host, &c.ExtendedConfigCache)
81134
var rootFilesWatch *watchedFiles[[]string]
82135
client := c.Host.Client()
83136
if c.Host.IsWatchEnabled() && client != nil {
84-
rootFilesWatch = newWatchedFiles(&configFileWatchHost{fileName: fileName, host: c.Host}, lsproto.WatchKindChange|lsproto.WatchKindCreate|lsproto.WatchKindDelete, core.Identity, "root files")
137+
rootFilesWatch = newWatchedFiles(&configFileWatchHost{fileName: fileName, host: c.Host},
138+
lsproto.WatchKindChange|lsproto.WatchKindCreate|lsproto.WatchKindDelete,
139+
core.Identity, "root files")
85140
}
86-
entry, _ = c.ConfigFiles.LoadOrStore(path, &ConfigFileEntry{
141+
142+
newEntry := &ConfigFileEntry{
87143
commandLine: config,
88-
pendingReload: PendingReloadFull,
144+
pendingReload: PendingReloadNone, // Already parsed, no reload needed
89145
rootFilesWatch: rootFilesWatch,
90-
})
146+
}
147+
148+
entry, loaded = c.ConfigFiles.LoadOrStore(path, newEntry)
149+
if !loaded {
150+
c.updateRootFilesWatch(fileName, entry)
151+
c.updateExtendedConfigsUsedBy(path, entry, nil)
152+
}
91153
}
154+
92155
entry.mu.Lock()
93156
defer entry.mu.Unlock()
157+
94158
if project != nil {
95159
entry.projects.Add(project)
96160
} else if info != nil {
97161
entry.infos.Add(info)
98162
}
99-
if entry.pendingReload == PendingReloadNone {
100-
return entry.commandLine
101-
}
102-
switch entry.pendingReload {
103-
case PendingReloadFileNames:
104-
entry.commandLine = entry.commandLine.ReloadFileNamesOfParsedCommandLine(c.Host.FS())
105-
case PendingReloadFull:
106-
oldCommandLine := entry.commandLine
107-
entry.commandLine, _ = tsoptions.GetParsedCommandLineOfConfigFilePath(fileName, path, nil, c.Host, &c.ExtendedConfigCache)
108-
c.updateExtendedConfigsUsedBy(path, entry, oldCommandLine)
109-
c.updateRootFilesWatch(fileName, entry)
163+
164+
if entry.pendingReload != PendingReloadNone {
165+
switch entry.pendingReload {
166+
case PendingReloadFileNames:
167+
entry.commandLine = entry.commandLine.ReloadFileNamesOfParsedCommandLine(c.Host.FS())
168+
case PendingReloadFull:
169+
oldCommandLine := entry.commandLine
170+
entry.commandLine, _ = tsoptions.GetParsedCommandLineOfConfigFilePath(fileName, path, nil, c.Host, &c.ExtendedConfigCache)
171+
c.updateExtendedConfigsUsedBy(path, entry, oldCommandLine)
172+
c.updateRootFilesWatch(fileName, entry)
173+
}
174+
entry.pendingReload = PendingReloadNone
110175
}
111-
entry.pendingReload = PendingReloadNone
176+
112177
return entry.commandLine
113178
}
114179

@@ -164,17 +229,19 @@ func (c *ConfigFileRegistry) updateExtendedConfigsUsedBy(path tspath.Path, entry
164229
extendedEntry.configFiles.Add(path)
165230
extendedEntry.mu.Unlock()
166231
}
167-
for _, extendedConfig := range oldCommandLine.ExtendedSourceFiles() {
168-
extendedPath := tspath.ToPath(extendedConfig, c.Host.GetCurrentDirectory(), c.Host.FS().UseCaseSensitiveFileNames())
169-
if !slices.Contains(newConfigs, extendedPath) {
170-
extendedEntry, _ := c.ExtendedConfigsUsedBy.Load(extendedPath)
171-
extendedEntry.mu.Lock()
172-
extendedEntry.configFiles.Delete(path)
173-
if extendedEntry.configFiles.Len() == 0 {
174-
c.ExtendedConfigsUsedBy.Delete(extendedPath)
175-
c.ExtendedConfigCache.Delete(extendedPath)
232+
if oldCommandLine != nil {
233+
for _, extendedConfig := range oldCommandLine.ExtendedSourceFiles() {
234+
extendedPath := tspath.ToPath(extendedConfig, c.Host.GetCurrentDirectory(), c.Host.FS().UseCaseSensitiveFileNames())
235+
if !slices.Contains(newConfigs, extendedPath) {
236+
extendedEntry, _ := c.ExtendedConfigsUsedBy.Load(extendedPath)
237+
extendedEntry.mu.Lock()
238+
extendedEntry.configFiles.Delete(path)
239+
if extendedEntry.configFiles.Len() == 0 {
240+
c.ExtendedConfigsUsedBy.Delete(extendedPath)
241+
c.ExtendedConfigCache.Delete(extendedPath)
242+
}
243+
extendedEntry.mu.Unlock()
176244
}
177-
extendedEntry.mu.Unlock()
178245
}
179246
}
180247
}

internal/project/defaultprojectfinder.go

Lines changed: 100 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"strings"
66
"sync"
77

8-
"github.com/microsoft/typescript-go/internal/collections"
98
"github.com/microsoft/typescript-go/internal/core"
109
"github.com/microsoft/typescript-go/internal/tsoptions"
1110
"github.com/microsoft/typescript-go/internal/tspath"
@@ -190,33 +189,82 @@ func (f *defaultProjectFinder) tryFindDefaultConfiguredProjectFromReferences(
190189
if len(config.ProjectReferences()) == 0 {
191190
return false
192191
}
193-
wg := core.NewWorkGroup(false)
194-
f.tryFindDefaultConfiguredProjectFromReferencesWorker(info, config, loadKind, result, wg)
195-
wg.RunAndWait()
196-
return result.isDone()
197-
}
198192

199-
func (f *defaultProjectFinder) tryFindDefaultConfiguredProjectFromReferencesWorker(
200-
info *ScriptInfo,
201-
config *tsoptions.ParsedCommandLine,
202-
loadKind projectLoadKind,
203-
result *openScriptInfoProjectResult,
204-
wg core.WorkGroup,
205-
) {
193+
194+
type configItem struct {
195+
fileName string
196+
path tspath.Path
197+
config *tsoptions.ParsedCommandLine
198+
loadKind projectLoadKind
199+
}
200+
201+
type visitKey struct {
202+
path tspath.Path
203+
loadKind projectLoadKind
204+
}
205+
206+
var queue []configItem
207+
visited := make(map[visitKey]bool)
208+
209+
initialLoadKind := loadKind
206210
if config.CompilerOptions().DisableReferencedProjectLoad.IsTrue() {
207-
loadKind = projectLoadKindFind
211+
initialLoadKind = projectLoadKindFind
208212
}
213+
209214
for _, childConfigFileName := range config.ResolvedProjectReferencePaths() {
210-
wg.Queue(func() {
215+
childConfigFilePath := f.service.toPath(childConfigFileName)
216+
key := visitKey{path: childConfigFilePath, loadKind: initialLoadKind}
217+
if !visited[key] {
218+
visited[key] = true
219+
childConfig := f.findOrAcquireConfig(info, childConfigFileName, childConfigFilePath, initialLoadKind)
220+
if childConfig != nil {
221+
queue = append(queue, configItem{
222+
fileName: childConfigFileName,
223+
path: childConfigFilePath,
224+
config: childConfig,
225+
loadKind: initialLoadKind,
226+
})
227+
}
228+
}
229+
}
230+
231+
for len(queue) > 0 {
232+
current := queue[0]
233+
queue = queue[1:]
234+
235+
if f.isDefaultConfigForScriptInfo(info, current.fileName, current.path, current.config, current.loadKind, result) {
236+
return true
237+
}
238+
239+
if result.isDone() {
240+
return true
241+
}
242+
243+
childLoadKind := current.loadKind
244+
if current.config.CompilerOptions().DisableReferencedProjectLoad.IsTrue() {
245+
childLoadKind = projectLoadKindFind
246+
}
247+
248+
for _, childConfigFileName := range current.config.ResolvedProjectReferencePaths() {
211249
childConfigFilePath := f.service.toPath(childConfigFileName)
212-
childConfig := f.findOrAcquireConfig(info, childConfigFileName, childConfigFilePath, loadKind)
213-
if childConfig == nil || f.isDefaultConfigForScriptInfo(info, childConfigFileName, childConfigFilePath, childConfig, loadKind, result) {
214-
return
250+
key := visitKey{path: childConfigFilePath, loadKind: childLoadKind}
251+
252+
if !visited[key] {
253+
visited[key] = true
254+
childConfig := f.findOrAcquireConfig(info, childConfigFileName, childConfigFilePath, childLoadKind)
255+
if childConfig != nil {
256+
queue = append(queue, configItem{
257+
fileName: childConfigFileName,
258+
path: childConfigFilePath,
259+
config: childConfig,
260+
loadKind: childLoadKind,
261+
})
262+
}
215263
}
216-
// Search in references if we cant find default project in current config
217-
f.tryFindDefaultConfiguredProjectFromReferencesWorker(info, childConfig, loadKind, result, wg)
218-
})
264+
}
219265
}
266+
267+
return result.isDone()
220268
}
221269

222270
func (f *defaultProjectFinder) tryFindDefaultConfiguredProjectFromAncestor(
@@ -318,57 +366,70 @@ func (f *defaultProjectFinder) findDefaultConfiguredProject(scriptInfo *ScriptIn
318366
}
319367

320368
type openScriptInfoProjectResult struct {
321-
projectMu sync.RWMutex
322-
project *Project
323-
fallbackDefaultMu sync.RWMutex
324-
fallbackDefault *Project // use this if we cant find actual project
325-
seenProjects collections.SyncMap[*Project, projectLoadKind]
326-
seenConfigs collections.SyncMap[tspath.Path, projectLoadKind]
369+
mu sync.Mutex
370+
project *Project
371+
fallbackDefault *Project // use this if we cant find actual project
372+
seenProjects map[*Project]projectLoadKind
373+
seenConfigs map[tspath.Path]projectLoadKind
327374
}
328375

329376
func (r *openScriptInfoProjectResult) addSeenProject(project *Project, loadKind projectLoadKind) bool {
330-
if kind, loaded := r.seenProjects.LoadOrStore(project, loadKind); loaded {
377+
r.mu.Lock()
378+
defer r.mu.Unlock()
379+
380+
if r.seenProjects == nil {
381+
r.seenProjects = make(map[*Project]projectLoadKind)
382+
}
383+
384+
if kind, exists := r.seenProjects[project]; exists {
331385
if kind >= loadKind {
332386
return false
333387
}
334-
r.seenProjects.Store(project, loadKind)
335388
}
389+
r.seenProjects[project] = loadKind
336390
return true
337391
}
338392

339393
func (r *openScriptInfoProjectResult) addSeenConfig(configPath tspath.Path, loadKind projectLoadKind) bool {
340-
if kind, loaded := r.seenConfigs.LoadOrStore(configPath, loadKind); loaded {
394+
r.mu.Lock()
395+
defer r.mu.Unlock()
396+
397+
if r.seenConfigs == nil {
398+
r.seenConfigs = make(map[tspath.Path]projectLoadKind)
399+
}
400+
401+
if kind, exists := r.seenConfigs[configPath]; exists {
341402
if kind >= loadKind {
342403
return false
343404
}
344-
r.seenConfigs.Store(configPath, loadKind)
345405
}
406+
r.seenConfigs[configPath] = loadKind
346407
return true
347408
}
348409

349410
func (r *openScriptInfoProjectResult) isDone() bool {
350-
r.projectMu.RLock()
351-
defer r.projectMu.RUnlock()
411+
r.mu.Lock()
412+
defer r.mu.Unlock()
352413
return r.project != nil
353414
}
354415

355416
func (r *openScriptInfoProjectResult) setProject(project *Project) {
356-
r.projectMu.Lock()
357-
defer r.projectMu.Unlock()
417+
r.mu.Lock()
418+
defer r.mu.Unlock()
358419
if r.project == nil {
359420
r.project = project
360421
}
361422
}
362423

363424
func (r *openScriptInfoProjectResult) hasFallbackDefault() bool {
364-
r.fallbackDefaultMu.RLock()
365-
defer r.fallbackDefaultMu.RUnlock()
425+
r.mu.Lock()
426+
defer r.mu.Unlock()
366427
return r.fallbackDefault != nil
367428
}
368429

369430
func (r *openScriptInfoProjectResult) setFallbackDefault(project *Project) {
370-
r.fallbackDefaultMu.Lock()
371-
defer r.fallbackDefaultMu.Unlock()
431+
r.mu.Lock()
432+
defer r.mu.Unlock()
372433
if r.fallbackDefault == nil {
373434
r.fallbackDefault = project
374435
}

0 commit comments

Comments
 (0)