Skip to content

Commit 471c1f1

Browse files
authored
Fix crash caused by repeatedly cloning the same path hierarchy node (#1220)
* Don't repeatedly clone nodes that have already been cloned rdar://150706871 * Fix unrelated code warning about unused local variable * Fix unrelated list of symbols in other assertion message * Avoid repetition in debug assertions * Only create sparse nodes after processing all symbol graph files * Try to find existing nodes before creating sparse nodes rdar://148247074 * Remove trailing commas for compatibility before Swift 6.1 * Remove unintentional print statement * Try to repair symbol graphs with deep hierarchies but no actual memberOf relationships * Add additional test assertion about counterpart references
1 parent ba971c1 commit 471c1f1

File tree

2 files changed

+288
-53
lines changed

2 files changed

+288
-53
lines changed

Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift

Lines changed: 94 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2022-2024 Apple Inc. and the Swift project authors
4+
Copyright (c) 2022-2025 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See https://swift.org/LICENSE.txt for license information
@@ -74,7 +74,11 @@ struct PathHierarchy {
7474
.sorted(by: { lhs, rhs in
7575
return !lhs.url.lastPathComponent.contains("@")
7676
})
77-
77+
78+
// To try to handle certain invalid symbol graph files gracefully, we track symbols that don't have a place in the hierarchy so that we can look for a place for those symbols.
79+
// Because this is a last resort, we only want to do this processing after all the symbol graphs have already been processed.
80+
var symbolNodesOutsideOfHierarchyByModule: [String: [Node]] = [:]
81+
7882
for (url, graph, language) in symbolGraphs {
7983
let moduleName = graph.module.name
8084
let moduleNode: Node
@@ -115,7 +119,10 @@ struct PathHierarchy {
115119
|| ($0.symbol!.pathComponents == symbol.pathComponents && $0.symbol!.kind.identifier == symbol.kind.identifier)
116120
}) {
117121
nodes[id] = existingNode
118-
existingNode.languages.insert(language!) // If we have symbols in this graph we have a language as well
122+
if existingNode.counterpart?.languages.contains(language!) != true {
123+
// Unless this symbol is already split into language counterparts, add the languages to this node.
124+
existingNode.languages.insert(language!)
125+
}
119126
} else {
120127
assert(!symbol.pathComponents.isEmpty, "A symbol should have at least its own name in its path components.")
121128

@@ -150,7 +157,7 @@ struct PathHierarchy {
150157
if let targetNode = nodes[relationship.target], targetNode.name == expectedContainerName {
151158
if sourceNode.parent == nil {
152159
targetNode.add(symbolChild: sourceNode)
153-
} else if sourceNode.parent !== targetNode {
160+
} else if sourceNode.parent !== targetNode && sourceNode.counterpart?.parent !== targetNode {
154161
// If the node we have for the child has an existing parent that doesn't
155162
// match the parent from this symbol graph, we need to clone the child to
156163
// ensure that the hierarchy remains consistent.
@@ -166,7 +173,7 @@ struct PathHierarchy {
166173
sourceNode.languages.remove(language!)
167174

168175
// Make sure that the clone's children can all line up with symbols from this symbol graph.
169-
for (childName, children) in sourceNode.children {
176+
for children in sourceNode.children.values {
170177
for child in children.storage {
171178
guard let childSymbol = child.node.symbol else {
172179
// We shouldn't come across any non-symbol nodes here,
@@ -198,8 +205,13 @@ struct PathHierarchy {
198205
// If the source was added in an extension symbol graph file, then its target won't be found in the same symbol graph file (in `nodes`).
199206

200207
// We may have encountered multiple language representations of the target symbol. Try to find the best matching representation of the target to add the source to.
201-
// Remove any targets that don't match the source symbol's path components (see comment above for more details).
202-
targetNodes.removeAll(where: { $0.name != expectedContainerName })
208+
// Remove any targets that don't match the source symbol's path components (see comment above for more details) and languages (see comments below).
209+
targetNodes.removeAll(where: { $0.name != expectedContainerName || $0.languages.isDisjoint(with: sourceNode.languages) })
210+
guard !targetNodes.isEmpty else {
211+
// If none of the symbol graphs contain a matching node it's likely a bug in the tool that generated the symbol graph.
212+
// If this happens we leave the source node in `topLevelCandidates` to try and let a later fallback code path recover from the symbol graph issue.
213+
continue
214+
}
203215

204216
// Prefer the symbol that matches the relationship's language.
205217
if let targetNode = targetNodes.first(where: { $0.symbol!.identifier.interfaceLanguage == language?.id }) {
@@ -208,7 +220,7 @@ struct PathHierarchy {
208220
// It's not clear which target to add the source to, so we add it to all of them.
209221
// This will likely hit a _debug_ assertion (later in this initializer) about inconsistent traversal through the hierarchy,
210222
// but in release builds DocC will "repair" the inconsistent hierarchy.
211-
for targetNode in targetNodes {
223+
for targetNode in targetNodes where !sourceNode.languages.isDisjoint(with: targetNode.languages) {
212224
targetNode.add(symbolChild: sourceNode)
213225
}
214226
}
@@ -256,14 +268,17 @@ struct PathHierarchy {
256268
moduleNode.add(symbolChild: topLevelNode)
257269
}
258270

259-
assert(
260-
topLevelCandidates.values.filter({ $0.symbol!.pathComponents.count > 1 }).allSatisfy({ $0.parent == nil }), """
261-
Top-level candidates shouldn't already exist in the hierarchy. \
262-
This wasn't true for \(topLevelCandidates.filter({ $0.value.symbol!.pathComponents.count > 1 && $0.value.parent != nil }).map(\.key).sorted())
263-
"""
264-
)
271+
assertAllNodes(in: topLevelCandidates.values.filter { $0.symbol!.pathComponents.count > 1 }, satisfy: { $0.parent == nil },
272+
"Top-level candidates shouldn't already exist in the hierarchy.")
265273

266274
for node in topLevelCandidates.values where node.symbol!.pathComponents.count > 1 && node.parent == nil {
275+
symbolNodesOutsideOfHierarchyByModule[moduleNode.symbol!.identifier.precise, default: []].append(node)
276+
}
277+
}
278+
279+
for (moduleID, nodes) in symbolNodesOutsideOfHierarchyByModule {
280+
let moduleNode = roots[moduleID]!
281+
for node in nodes where node.parent == nil {
267282
var parent = moduleNode
268283
var components = { (symbol: SymbolGraph.Symbol) -> [String] in
269284
let original = symbol.pathComponents
@@ -281,10 +296,31 @@ struct PathHierarchy {
281296
components = components.dropFirst()
282297
}
283298
for component in components {
299+
// FIXME:
300+
// This code path is both expected (when `knownDisambiguatedPathComponents` is non-nil) and unexpected (when the symbol graph is missing data or contains extra relationships).
301+
// It would be good to restructure this code to better distinguish what's supported behavior and what's a best-effort attempt at gracefully handle invalid symbol graphs.
302+
if let existing = parent.children[component] {
303+
// This code tries to repair incomplete symbol graph files by guessing that the symbol with the most overlapping languages is the intended container.
304+
// Valid symbol graph files we should never end up here.
305+
var bestLanguageMatch: (node: Node, count: Int)?
306+
for element in existing.storage {
307+
let numberOfMatchingLanguages = node.languages.intersection(element.node.languages).count
308+
if (bestLanguageMatch?.count ?? .min) < numberOfMatchingLanguages {
309+
bestLanguageMatch = (node: element.node, count: numberOfMatchingLanguages)
310+
}
311+
}
312+
if let bestLanguageMatch {
313+
// If there's a real symbol that matches this node's languages, use that node instead of creating a placeholder node
314+
parent = bestLanguageMatch.node
315+
continue
316+
}
317+
}
318+
284319
assert(
285-
parent.children[components.first!] == nil,
320+
parent.children[component] == nil,
286321
"Shouldn't create a new sparse node when symbol node already exist. This is an indication that a symbol is missing a relationship."
287322
)
323+
288324
guard knownDisambiguatedPathComponents != nil else {
289325
// If the path hierarchy wasn't passed any "known disambiguated path components" then the sparse/placeholder nodes won't contain any disambiguation.
290326
let nodeWithoutSymbol = Node(name: component)
@@ -352,21 +388,11 @@ struct PathHierarchy {
352388
}
353389
}
354390

355-
assert(
356-
allNodes.allSatisfy({ $0.value[0].parent != nil || roots[$0.key] != nil }), """
357-
Every node should either have a parent node or be a root node. \
358-
This wasn't true for \(allNodes.filter({ $0.value[0].parent == nil && roots[$0.key] == nil }).map(\.key).sorted())
359-
"""
360-
)
391+
assertAllNodes(in: allNodes, satisfy: { $0.parent != nil || roots[$0.symbol!.identifier.precise] != nil },
392+
"Every node should either have a parent node or be a root node.")
361393

362-
assert(
363-
allNodes.values.allSatisfy({ nodesWithSameUSR in nodesWithSameUSR.allSatisfy({ node in
364-
Array(sequence(first: node, next: \.parent)).last!.symbol!.kind.identifier == .module })
365-
}), """
366-
Every node should reach a root node by following its parents up. \
367-
This wasn't true for \(allNodes.filter({ $0.value.allSatisfy({ Array(sequence(first: $0, next: \.parent)).last!.symbol!.kind.identifier != .module }) }).map(\.key).sorted())
368-
"""
369-
)
394+
assertAllNodes(in: allNodes, satisfy: { Array(sequence(first: $0, next: \.parent)).last!.symbol!.kind.identifier == .module },
395+
"Every node should reach a root node by following its parents up.")
370396

371397
allNodes.removeAll()
372398

@@ -407,12 +433,11 @@ struct PathHierarchy {
407433
descend(module)
408434
}
409435

410-
assert(
411-
lookup.allSatisfy({ $0.value.parent != nil || roots[$0.value.name] != nil }), """
412-
Every node should either have a parent node or be a root node. \
413-
This wasn't true for \(allNodes.filter({ $0.value[0].parent == nil && roots[$0.key] == nil }).map(\.key).sorted())
414-
"""
415-
)
436+
assertAllNodes(in: lookup.values, satisfy: { $0.parent != nil || roots[$0.name] != nil },
437+
"Every node should either have a parent node or be a root node.")
438+
439+
assertAllNodes(in: lookup.values, satisfy: { $0.counterpart == nil || lookup[$0.counterpart!.identifier] != nil },
440+
"Every counterpart node should exist in the hierarchy.")
416441

417442
func newNode(_ name: String) -> Node {
418443
let id = ResolvedIdentifier()
@@ -430,12 +455,8 @@ struct PathHierarchy {
430455
"Every node lookup should match a node with that identifier."
431456
)
432457

433-
assert(
434-
lookup.values.allSatisfy({ $0.parent?.identifier == nil || lookup[$0.parent!.identifier] != nil }), """
435-
Every node's findable parent should exist in the lookup. \
436-
This wasn't true for \(lookup.values.filter({ $0.parent?.identifier != nil && lookup[$0.parent!.identifier] == nil }).map(\.symbol!.identifier.precise).sorted())
437-
"""
438-
)
458+
assertAllNodes(in: lookup.values, satisfy: { $0.parent?.identifier == nil || lookup[$0.parent!.identifier] != nil },
459+
"Every node's findable parent should exist in the lookup.")
439460

440461
self.modules = Array(roots.values)
441462
self.lookup = lookup
@@ -884,3 +905,35 @@ extension LinkCompletionTools {
884905
return (node, id)
885906
}
886907
}
908+
909+
// MARK: Assertion
910+
911+
private func assertAllNodes(
912+
in collection: @autoclosure () -> some Sequence<PathHierarchy.Node>,
913+
satisfy condition: (PathHierarchy.Node) -> Bool,
914+
_ message: @autoclosure () -> String,
915+
file: StaticString = #file,
916+
line: UInt = #line
917+
) {
918+
assert(
919+
collection().allSatisfy(condition),
920+
"\(message()) This wasn't true for \(collection().filter { !condition($0) }.map(\.symbol!.identifier.precise).sorted())",
921+
file: file,
922+
line: line
923+
)
924+
}
925+
926+
private func assertAllNodes(
927+
in collectionsByStringKey: @autoclosure () -> [String: some Collection<PathHierarchy.Node>],
928+
satisfy condition: (PathHierarchy.Node) -> Bool,
929+
_ message: @autoclosure () -> String,
930+
file: StaticString = #file,
931+
line: UInt = #line
932+
) {
933+
assert(
934+
collectionsByStringKey().values.allSatisfy { $0.allSatisfy(condition) },
935+
"\(message()) This wasn't true for \(collectionsByStringKey().filter { $0.value.contains(where: { !condition($0)}) }.map(\.key).sorted())",
936+
file: file,
937+
line: line
938+
)
939+
}

0 commit comments

Comments
 (0)