Skip to content

Commit b7e764a

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 b7e764a

File tree

4 files changed

+214
-80
lines changed

4 files changed

+214
-80
lines changed

internal/api/api.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ func NewAPI(host APIHost, options APIOptions) *API {
6565
},
6666
})
6767

68-
api.configFileRegistry = &project.ConfigFileRegistry{
68+
api.configFileRegistry = project.NewConfigFileRegistry(&project.ConfigFileRegistry{
6969
Host: api,
70-
}
70+
})
7171
return api
7272
}
7373

internal/project/configfileregistry.go

Lines changed: 101 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,24 @@ 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
44+
}
45+
46+
func NewConfigFileRegistry(registry *ConfigFileRegistry) *ConfigFileRegistry {
47+
registry.activeParsing = make(map[tspath.Path]*parseStatus)
48+
return registry
3749
}
3850

3951
func (e *ConfigFileEntry) SetPendingReload(level PendingReload) bool {
@@ -74,41 +86,99 @@ func (c *ConfigFileRegistry) releaseConfig(path tspath.Path, project *Project) {
7486
}
7587

7688
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
89+
if entry, ok := c.ConfigFiles.Load(path); ok {
90+
entry.mu.RLock()
91+
needsReload := entry.pendingReload != PendingReloadNone
92+
commandLine := entry.commandLine
93+
entry.mu.RUnlock()
94+
95+
if !needsReload && commandLine != nil {
96+
entry.mu.Lock()
97+
if project != nil {
98+
entry.projects.Add(project)
99+
} else if info != nil {
100+
entry.infos.Add(info)
101+
}
102+
entry.mu.Unlock()
103+
return commandLine
104+
}
105+
}
106+
107+
c.parseMu.Lock()
108+
if status, ok := c.activeParsing[path]; ok {
109+
c.parseMu.Unlock()
110+
status.wg.Wait()
111+
112+
if entry, ok := c.ConfigFiles.Load(path); ok {
113+
entry.mu.Lock()
114+
defer entry.mu.Unlock()
115+
if project != nil {
116+
entry.projects.Add(project)
117+
} else if info != nil {
118+
entry.infos.Add(info)
119+
}
120+
return entry.commandLine
121+
}
122+
}
123+
124+
status := &parseStatus{wg: &sync.WaitGroup{}}
125+
status.wg.Add(1)
126+
c.activeParsing[path] = status
127+
c.parseMu.Unlock()
128+
129+
defer func() {
130+
status.wg.Done()
131+
c.parseMu.Lock()
132+
delete(c.activeParsing, path)
133+
c.parseMu.Unlock()
134+
}()
135+
136+
entry, loaded := c.ConfigFiles.Load(path)
137+
if !loaded {
80138
config, _ := tsoptions.GetParsedCommandLineOfConfigFilePath(fileName, path, nil, c.Host, &c.ExtendedConfigCache)
81139
var rootFilesWatch *watchedFiles[[]string]
82140
client := c.Host.Client()
83141
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")
142+
rootFilesWatch = newWatchedFiles(&configFileWatchHost{fileName: fileName, host: c.Host},
143+
lsproto.WatchKindChange|lsproto.WatchKindCreate|lsproto.WatchKindDelete,
144+
core.Identity, "root files")
85145
}
86-
entry, _ = c.ConfigFiles.LoadOrStore(path, &ConfigFileEntry{
146+
147+
newEntry := &ConfigFileEntry{
87148
commandLine: config,
88-
pendingReload: PendingReloadFull,
149+
pendingReload: PendingReloadNone, // Already parsed, no reload needed
89150
rootFilesWatch: rootFilesWatch,
90-
})
151+
}
152+
153+
entry, loaded = c.ConfigFiles.LoadOrStore(path, newEntry)
154+
if !loaded {
155+
c.updateRootFilesWatch(fileName, entry)
156+
c.updateExtendedConfigsUsedBy(path, entry, nil)
157+
}
91158
}
159+
92160
entry.mu.Lock()
93161
defer entry.mu.Unlock()
162+
94163
if project != nil {
95164
entry.projects.Add(project)
96165
} else if info != nil {
97166
entry.infos.Add(info)
98167
}
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)
168+
169+
if entry.pendingReload != PendingReloadNone {
170+
switch entry.pendingReload {
171+
case PendingReloadFileNames:
172+
entry.commandLine = entry.commandLine.ReloadFileNamesOfParsedCommandLine(c.Host.FS())
173+
case PendingReloadFull:
174+
oldCommandLine := entry.commandLine
175+
entry.commandLine, _ = tsoptions.GetParsedCommandLineOfConfigFilePath(fileName, path, nil, c.Host, &c.ExtendedConfigCache)
176+
c.updateExtendedConfigsUsedBy(path, entry, oldCommandLine)
177+
c.updateRootFilesWatch(fileName, entry)
178+
}
179+
entry.pendingReload = PendingReloadNone
110180
}
111-
entry.pendingReload = PendingReloadNone
181+
112182
return entry.commandLine
113183
}
114184

@@ -164,17 +234,19 @@ func (c *ConfigFileRegistry) updateExtendedConfigsUsedBy(path tspath.Path, entry
164234
extendedEntry.configFiles.Add(path)
165235
extendedEntry.mu.Unlock()
166236
}
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)
237+
if oldCommandLine != nil {
238+
for _, extendedConfig := range oldCommandLine.ExtendedSourceFiles() {
239+
extendedPath := tspath.ToPath(extendedConfig, c.Host.GetCurrentDirectory(), c.Host.FS().UseCaseSensitiveFileNames())
240+
if !slices.Contains(newConfigs, extendedPath) {
241+
extendedEntry, _ := c.ExtendedConfigsUsedBy.Load(extendedPath)
242+
extendedEntry.mu.Lock()
243+
extendedEntry.configFiles.Delete(path)
244+
if extendedEntry.configFiles.Len() == 0 {
245+
c.ExtendedConfigsUsedBy.Delete(extendedPath)
246+
c.ExtendedConfigCache.Delete(extendedPath)
247+
}
248+
extendedEntry.mu.Unlock()
176249
}
177-
extendedEntry.mu.Unlock()
178250
}
179251
}
180252
}

internal/project/defaultprojectfinder.go

Lines changed: 99 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,81 @@ 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+
type configItem struct {
194+
fileName string
195+
path tspath.Path
196+
config *tsoptions.ParsedCommandLine
197+
loadKind projectLoadKind
198+
}
199+
200+
type visitKey struct {
201+
path tspath.Path
202+
loadKind projectLoadKind
203+
}
204+
205+
var queue []configItem
206+
visited := make(map[visitKey]bool)
207+
208+
initialLoadKind := loadKind
206209
if config.CompilerOptions().DisableReferencedProjectLoad.IsTrue() {
207-
loadKind = projectLoadKindFind
210+
initialLoadKind = projectLoadKindFind
208211
}
212+
209213
for _, childConfigFileName := range config.ResolvedProjectReferencePaths() {
210-
wg.Queue(func() {
214+
childConfigFilePath := f.service.toPath(childConfigFileName)
215+
key := visitKey{path: childConfigFilePath, loadKind: initialLoadKind}
216+
if !visited[key] {
217+
visited[key] = true
218+
childConfig := f.findOrAcquireConfig(info, childConfigFileName, childConfigFilePath, initialLoadKind)
219+
if childConfig != nil {
220+
queue = append(queue, configItem{
221+
fileName: childConfigFileName,
222+
path: childConfigFilePath,
223+
config: childConfig,
224+
loadKind: initialLoadKind,
225+
})
226+
}
227+
}
228+
}
229+
230+
for len(queue) > 0 {
231+
current := queue[0]
232+
queue = queue[1:]
233+
234+
if f.isDefaultConfigForScriptInfo(info, current.fileName, current.path, current.config, current.loadKind, result) {
235+
return true
236+
}
237+
238+
if result.isDone() {
239+
return true
240+
}
241+
242+
childLoadKind := current.loadKind
243+
if current.config.CompilerOptions().DisableReferencedProjectLoad.IsTrue() {
244+
childLoadKind = projectLoadKindFind
245+
}
246+
247+
for _, childConfigFileName := range current.config.ResolvedProjectReferencePaths() {
211248
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
249+
key := visitKey{path: childConfigFilePath, loadKind: childLoadKind}
250+
251+
if !visited[key] {
252+
visited[key] = true
253+
childConfig := f.findOrAcquireConfig(info, childConfigFileName, childConfigFilePath, childLoadKind)
254+
if childConfig != nil {
255+
queue = append(queue, configItem{
256+
fileName: childConfigFileName,
257+
path: childConfigFilePath,
258+
config: childConfig,
259+
loadKind: childLoadKind,
260+
})
261+
}
215262
}
216-
// Search in references if we cant find default project in current config
217-
f.tryFindDefaultConfiguredProjectFromReferencesWorker(info, childConfig, loadKind, result, wg)
218-
})
263+
}
219264
}
265+
266+
return result.isDone()
220267
}
221268

222269
func (f *defaultProjectFinder) tryFindDefaultConfiguredProjectFromAncestor(
@@ -318,57 +365,70 @@ func (f *defaultProjectFinder) findDefaultConfiguredProject(scriptInfo *ScriptIn
318365
}
319366

320367
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]
368+
mu sync.Mutex
369+
project *Project
370+
fallbackDefault *Project // use this if we cant find actual project
371+
seenProjects map[*Project]projectLoadKind
372+
seenConfigs map[tspath.Path]projectLoadKind
327373
}
328374

329375
func (r *openScriptInfoProjectResult) addSeenProject(project *Project, loadKind projectLoadKind) bool {
330-
if kind, loaded := r.seenProjects.LoadOrStore(project, loadKind); loaded {
376+
r.mu.Lock()
377+
defer r.mu.Unlock()
378+
379+
if r.seenProjects == nil {
380+
r.seenProjects = make(map[*Project]projectLoadKind)
381+
}
382+
383+
if kind, exists := r.seenProjects[project]; exists {
331384
if kind >= loadKind {
332385
return false
333386
}
334-
r.seenProjects.Store(project, loadKind)
335387
}
388+
r.seenProjects[project] = loadKind
336389
return true
337390
}
338391

339392
func (r *openScriptInfoProjectResult) addSeenConfig(configPath tspath.Path, loadKind projectLoadKind) bool {
340-
if kind, loaded := r.seenConfigs.LoadOrStore(configPath, loadKind); loaded {
393+
r.mu.Lock()
394+
defer r.mu.Unlock()
395+
396+
if r.seenConfigs == nil {
397+
r.seenConfigs = make(map[tspath.Path]projectLoadKind)
398+
}
399+
400+
if kind, exists := r.seenConfigs[configPath]; exists {
341401
if kind >= loadKind {
342402
return false
343403
}
344-
r.seenConfigs.Store(configPath, loadKind)
345404
}
405+
r.seenConfigs[configPath] = loadKind
346406
return true
347407
}
348408

349409
func (r *openScriptInfoProjectResult) isDone() bool {
350-
r.projectMu.RLock()
351-
defer r.projectMu.RUnlock()
410+
r.mu.Lock()
411+
defer r.mu.Unlock()
352412
return r.project != nil
353413
}
354414

355415
func (r *openScriptInfoProjectResult) setProject(project *Project) {
356-
r.projectMu.Lock()
357-
defer r.projectMu.Unlock()
416+
r.mu.Lock()
417+
defer r.mu.Unlock()
358418
if r.project == nil {
359419
r.project = project
360420
}
361421
}
362422

363423
func (r *openScriptInfoProjectResult) hasFallbackDefault() bool {
364-
r.fallbackDefaultMu.RLock()
365-
defer r.fallbackDefaultMu.RUnlock()
424+
r.mu.Lock()
425+
defer r.mu.Unlock()
366426
return r.fallbackDefault != nil
367427
}
368428

369429
func (r *openScriptInfoProjectResult) setFallbackDefault(project *Project) {
370-
r.fallbackDefaultMu.Lock()
371-
defer r.fallbackDefaultMu.Unlock()
430+
r.mu.Lock()
431+
defer r.mu.Unlock()
372432
if r.fallbackDefault == nil {
373433
r.fallbackDefault = project
374434
}

0 commit comments

Comments
 (0)