Skip to content

Commit d28e000

Browse files
authored
Fix BFS loop during config lookup (#1648)
1 parent 71accec commit d28e000

File tree

5 files changed

+94
-39
lines changed

5 files changed

+94
-39
lines changed

internal/core/bfs.go

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,43 +8,43 @@ import (
88
"github.com/microsoft/typescript-go/internal/collections"
99
)
1010

11-
type BreadthFirstSearchResult[N comparable] struct {
11+
type BreadthFirstSearchResult[N any] struct {
1212
Stopped bool
1313
Path []N
1414
}
1515

16-
type breadthFirstSearchJob[N comparable] struct {
16+
type breadthFirstSearchJob[N any] struct {
1717
node N
1818
parent *breadthFirstSearchJob[N]
1919
}
2020

21-
type BreadthFirstSearchLevel[N comparable] struct {
22-
jobs *collections.OrderedMap[N, *breadthFirstSearchJob[N]]
21+
type BreadthFirstSearchLevel[K comparable, N any] struct {
22+
jobs *collections.OrderedMap[K, *breadthFirstSearchJob[N]]
2323
}
2424

25-
func (l *BreadthFirstSearchLevel[N]) Has(node N) bool {
26-
return l.jobs.Has(node)
25+
func (l *BreadthFirstSearchLevel[K, N]) Has(key K) bool {
26+
return l.jobs.Has(key)
2727
}
2828

29-
func (l *BreadthFirstSearchLevel[N]) Delete(node N) {
30-
l.jobs.Delete(node)
29+
func (l *BreadthFirstSearchLevel[K, N]) Delete(key K) {
30+
l.jobs.Delete(key)
3131
}
3232

33-
func (l *BreadthFirstSearchLevel[N]) Range(f func(node N) bool) {
34-
for node := range l.jobs.Keys() {
35-
if !f(node) {
33+
func (l *BreadthFirstSearchLevel[K, N]) Range(f func(node N) bool) {
34+
for job := range l.jobs.Values() {
35+
if !f(job.node) {
3636
return
3737
}
3838
}
3939
}
4040

41-
type BreadthFirstSearchOptions[N comparable] struct {
41+
type BreadthFirstSearchOptions[K comparable, N any] struct {
4242
// Visited is a set of nodes that have already been visited.
4343
// If nil, a new set will be created.
44-
Visited *collections.SyncSet[N]
44+
Visited *collections.SyncSet[K]
4545
// PreprocessLevel is a function that, if provided, will be called
4646
// before each level, giving the caller an opportunity to remove nodes.
47-
PreprocessLevel func(*BreadthFirstSearchLevel[N])
47+
PreprocessLevel func(*BreadthFirstSearchLevel[K, N])
4848
}
4949

5050
// BreadthFirstSearchParallel performs a breadth-first search on a graph
@@ -55,41 +55,42 @@ func BreadthFirstSearchParallel[N comparable](
5555
neighbors func(N) []N,
5656
visit func(node N) (isResult bool, stop bool),
5757
) BreadthFirstSearchResult[N] {
58-
return BreadthFirstSearchParallelEx(start, neighbors, visit, BreadthFirstSearchOptions[N]{})
58+
return BreadthFirstSearchParallelEx(start, neighbors, visit, BreadthFirstSearchOptions[N, N]{}, Identity)
5959
}
6060

6161
// BreadthFirstSearchParallelEx is an extension of BreadthFirstSearchParallel that allows
6262
// the caller to pass a pre-seeded set of already-visited nodes and a preprocessing function
6363
// that can be used to remove nodes from each level before parallel processing.
64-
func BreadthFirstSearchParallelEx[N comparable](
64+
func BreadthFirstSearchParallelEx[K comparable, N any](
6565
start N,
6666
neighbors func(N) []N,
6767
visit func(node N) (isResult bool, stop bool),
68-
options BreadthFirstSearchOptions[N],
68+
options BreadthFirstSearchOptions[K, N],
69+
getKey func(N) K,
6970
) BreadthFirstSearchResult[N] {
7071
visited := options.Visited
7172
if visited == nil {
72-
visited = &collections.SyncSet[N]{}
73+
visited = &collections.SyncSet[K]{}
7374
}
7475

7576
type result struct {
7677
stop bool
7778
job *breadthFirstSearchJob[N]
78-
next *collections.OrderedMap[N, *breadthFirstSearchJob[N]]
79+
next *collections.OrderedMap[K, *breadthFirstSearchJob[N]]
7980
}
8081

8182
var fallback *breadthFirstSearchJob[N]
8283
// processLevel processes each node at the current level in parallel.
8384
// It produces either a list of jobs to be processed in the next level,
8485
// or a result if the visit function returns true for any node.
85-
processLevel := func(index int, jobs *collections.OrderedMap[N, *breadthFirstSearchJob[N]]) result {
86+
processLevel := func(index int, jobs *collections.OrderedMap[K, *breadthFirstSearchJob[N]]) result {
8687
var lowestFallback atomic.Int64
8788
var lowestGoal atomic.Int64
8889
var nextJobCount atomic.Int64
8990
lowestGoal.Store(math.MaxInt64)
9091
lowestFallback.Store(math.MaxInt64)
9192
if options.PreprocessLevel != nil {
92-
options.PreprocessLevel(&BreadthFirstSearchLevel[N]{jobs: jobs})
93+
options.PreprocessLevel(&BreadthFirstSearchLevel[K, N]{jobs: jobs})
9394
}
9495
next := make([][]*breadthFirstSearchJob[N], jobs.Size())
9596
var wg sync.WaitGroup
@@ -103,7 +104,7 @@ func BreadthFirstSearchParallelEx[N comparable](
103104
}
104105

105106
// If we have already visited this node, skip it.
106-
if !visited.AddIfAbsent(j.node) {
107+
if !visited.AddIfAbsent(getKey(j.node)) {
107108
// Note that if we are here, we already visited this node at a
108109
// previous *level*, which means `visit` must have returned false,
109110
// so we don't need to update our result indices. This holds true
@@ -152,13 +153,13 @@ func BreadthFirstSearchParallelEx[N comparable](
152153
_, fallback, _ = jobs.EntryAt(int(index))
153154
}
154155
}
155-
nextJobs := collections.NewOrderedMapWithSizeHint[N, *breadthFirstSearchJob[N]](int(nextJobCount.Load()))
156+
nextJobs := collections.NewOrderedMapWithSizeHint[K, *breadthFirstSearchJob[N]](int(nextJobCount.Load()))
156157
for _, jobs := range next {
157158
for _, j := range jobs {
158-
if !nextJobs.Has(j.node) {
159+
if !nextJobs.Has(getKey(j.node)) {
159160
// Deduplicate synchronously to avoid messy locks and spawning
160161
// unnecessary goroutines.
161-
nextJobs.Set(j.node, j)
162+
nextJobs.Set(getKey(j.node), j)
162163
}
163164
}
164165
}
@@ -175,8 +176,8 @@ func BreadthFirstSearchParallelEx[N comparable](
175176
}
176177

177178
levelIndex := 0
178-
level := collections.NewOrderedMapFromList([]collections.MapEntry[N, *breadthFirstSearchJob[N]]{
179-
{Key: start, Value: &breadthFirstSearchJob[N]{node: start}},
179+
level := collections.NewOrderedMapFromList([]collections.MapEntry[K, *breadthFirstSearchJob[N]]{
180+
{Key: getKey(start), Value: &breadthFirstSearchJob[N]{node: start}},
180181
})
181182
for level.Size() > 0 {
182183
result := processLevel(levelIndex, level)

internal/core/bfs_test.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,10 @@ func TestBreadthFirstSearchParallel(t *testing.T) {
7878
var visited collections.SyncSet[string]
7979
core.BreadthFirstSearchParallelEx("Root", children, func(node string) (bool, bool) {
8080
return node == "L2B", true // Stop at level 2
81-
}, core.BreadthFirstSearchOptions[string]{
81+
}, core.BreadthFirstSearchOptions[string, string]{
8282
Visited: &visited,
83-
})
83+
},
84+
core.Identity)
8485

8586
assert.Assert(t, visited.Has("Root"), "Expected to visit Root")
8687
assert.Assert(t, visited.Has("L1A"), "Expected to visit L1A")
@@ -108,9 +109,10 @@ func TestBreadthFirstSearchParallel(t *testing.T) {
108109
var visited collections.SyncSet[string]
109110
result := core.BreadthFirstSearchParallelEx("A", children, func(node string) (bool, bool) {
110111
return node == "A", false // Record A as a fallback, but do not stop
111-
}, core.BreadthFirstSearchOptions[string]{
112+
}, core.BreadthFirstSearchOptions[string, string]{
112113
Visited: &visited,
113-
})
114+
},
115+
core.Identity)
114116

115117
assert.Equal(t, result.Stopped, false, "Expected search to not stop early")
116118
assert.DeepEqual(t, result.Path, []string{"A"})

internal/project/projectcollection.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,10 @@ func (c *ProjectCollection) findDefaultConfiguredProjectWorker(fileName string,
179179
}
180180
return false, false
181181
},
182-
core.BreadthFirstSearchOptions[*Project]{
182+
core.BreadthFirstSearchOptions[*Project, *Project]{
183183
Visited: visited,
184184
},
185+
core.Identity,
185186
)
186187

187188
if search.Stopped {

internal/project/projectcollectionbuilder.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,11 @@ type searchNode struct {
473473
logger *logging.LogTree
474474
}
475475

476+
type searchNodeKey struct {
477+
configFileName string
478+
loadKind projectLoadKind
479+
}
480+
476481
type searchResult struct {
477482
project *dirty.SyncMapEntry[tspath.Path, *Project]
478483
retain collections.Set[tspath.Path]
@@ -483,13 +488,13 @@ func (b *projectCollectionBuilder) findOrCreateDefaultConfiguredProjectWorker(
483488
path tspath.Path,
484489
configFileName string,
485490
loadKind projectLoadKind,
486-
visited *collections.SyncSet[searchNode],
491+
visited *collections.SyncSet[searchNodeKey],
487492
fallback *searchResult,
488493
logger *logging.LogTree,
489494
) searchResult {
490495
var configs collections.SyncMap[tspath.Path, *tsoptions.ParsedCommandLine]
491496
if visited == nil {
492-
visited = &collections.SyncSet[searchNode]{}
497+
visited = &collections.SyncSet[searchNodeKey]{}
493498
}
494499

495500
search := core.BreadthFirstSearchParallelEx(
@@ -558,18 +563,21 @@ func (b *projectCollectionBuilder) findOrCreateDefaultConfiguredProjectWorker(
558563
node.logger.Log("Project does not contain file")
559564
return false, false
560565
},
561-
core.BreadthFirstSearchOptions[searchNode]{
566+
core.BreadthFirstSearchOptions[searchNodeKey, searchNode]{
562567
Visited: visited,
563-
PreprocessLevel: func(level *core.BreadthFirstSearchLevel[searchNode]) {
568+
PreprocessLevel: func(level *core.BreadthFirstSearchLevel[searchNodeKey, searchNode]) {
564569
level.Range(func(node searchNode) bool {
565-
if node.loadKind == projectLoadKindFind && level.Has(searchNode{configFileName: node.configFileName, loadKind: projectLoadKindCreate, logger: node.logger}) {
570+
if node.loadKind == projectLoadKindFind && level.Has(searchNodeKey{configFileName: node.configFileName, loadKind: projectLoadKindCreate}) {
566571
// Remove find requests when a create request for the same project is already present.
567-
level.Delete(node)
572+
level.Delete(searchNodeKey{configFileName: node.configFileName, loadKind: node.loadKind})
568573
}
569574
return true
570575
})
571576
},
572577
},
578+
func(node searchNode) searchNodeKey {
579+
return searchNodeKey{configFileName: node.configFileName, loadKind: node.loadKind}
580+
},
573581
)
574582

575583
var retain collections.Set[tspath.Path]
@@ -626,7 +634,7 @@ func (b *projectCollectionBuilder) findOrCreateDefaultConfiguredProjectWorker(
626634
// If we didn't find anything, we can retain everything we visited,
627635
// since the whole graph must have been traversed (i.e., the set of
628636
// retained projects is guaranteed to be deterministic).
629-
visited.Range(func(node searchNode) bool {
637+
visited.Range(func(node searchNodeKey) bool {
630638
retain.Add(b.toPath(node.configFileName))
631639
return true
632640
})

internal/project/projectcollectionbuilder_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,49 @@ func TestProjectCollectionBuilder(t *testing.T) {
469469
"/project/c.ts",
470470
})
471471
})
472+
473+
t.Run("project lookup terminates", func(t *testing.T) {
474+
t.Parallel()
475+
files := map[string]any{
476+
"/tsconfig.json": `{
477+
"files": [],
478+
"references": [
479+
{
480+
"path": "./packages/pkg1"
481+
},
482+
{
483+
"path": "./packages/pkg2"
484+
},
485+
]
486+
}`,
487+
"/packages/pkg1/tsconfig.json": `{
488+
"include": ["src/**/*.ts"],
489+
"compilerOptions": {
490+
"composite": true,
491+
},
492+
"references": [
493+
{
494+
"path": "../pkg2"
495+
},
496+
]
497+
}`,
498+
"/packages/pkg2/tsconfig.json": `{
499+
"include": ["src/**/*.ts"],
500+
"compilerOptions": {
501+
"composite": true,
502+
},
503+
"references": [
504+
{
505+
"path": "../pkg1"
506+
},
507+
]
508+
}`,
509+
"/script.ts": `export const a = 1;`,
510+
}
511+
session, _ := projecttestutil.Setup(files)
512+
session.DidOpenFile(context.Background(), "file:///script.ts", 1, files["/script.ts"].(string), lsproto.LanguageKindTypeScript)
513+
// Test should terminate
514+
})
472515
}
473516

474517
func filesForSolutionConfigFile(solutionRefs []string, compilerOptions string, ownFiles []string) map[string]any {

0 commit comments

Comments
 (0)