Skip to content

Geko [patch] Prevent graph traversal stack overflows - #117

Open
igooor-bb wants to merge 7 commits into
geko-tech:mainfrom
igooor-bb:fix-graph-recursion-overflow
Open

Geko [patch] Prevent graph traversal stack overflows#117
igooor-bb wants to merge 7 commits into
geko-tech:mainfrom
igooor-bb:fix-graph-recursion-overflow

Conversation

@igooor-bb

@igooor-bb igooor-bb commented Jul 29, 2026

Copy link
Copy Markdown

Description

Large valid Geko dependency graphs can crash geko generate with a stack overflow when graph processing code uses recursive traversal.

This PR replaces the affected recursive traversals with iterative implementations that use explicit stacks:

  • topologicalSort and findCycle in GekoSupport
  • target dependency, resource bundle, searchable path, linkable dependency, Swift macro, platform condition, and supported-platform traversals in GraphTraverser
  • ModuleMapMapper
  • StaticProductsGraphLinter
  • XCFramework dependency traversal in SwiftModulesBuilder

The implementations preserve the existing traversal semantics, ordering, caching, collected metadata, and diagnostics where relevant, without growing the Swift call stack for every visited graph node.

The local topologicalSort replaces the implementation imported from TSC. Its upstream source (swiftlang/swift-tools-support-core) is deprecated, so fixing it there would not be useful for Geko.

Related Issue

This is the Geko counterpart of tuist/tuist#11350, which fixes the same class of recursive graph traversal problems in Tuist.

Geko had already made its circular dependency linter stack-safe in geko-tech/geko#92, and it does not contain Tuist's GraphCircularDetector, so those parts of the Tuist change did not need to be ported.

Motivation and Context

We encountered this problem in our project while using Tuist. Removing a single dependency edge unexpectedly changed the graph traversal shape enough to produce a very deep valid acyclic graph, after which project generation started crashing with a stack overflow.

We are also evaluating and integrating Geko, which contains several of the same recursive graph-processing paths. Preventing the same failure mode here makes that integration safer for our large workspace and protects other sufficiently deep Geko projects from depending on the process call-stack limit.

Replacing recursion with explicit traversal stacks makes the affected code stack-safe without requiring changes to an otherwise valid dependency graph. During validation of the initial Geko changes, fixing one set of recursive paths exposed another stack overflow in Swift macro traversal, demonstrating how a deep graph can reach these risks one processing stage at a time.

How Has This Been Tested?

Automated tests

The following affected test suites were run:

swift test --filter GraphAlgorithmsTests
swift test --filter GraphTraverserTests
swift test --filter ModuleMapMapperTests
swift test --filter StaticProductsGraphLinterTests
swift test --filter SwiftModulesBuilderTests

Results:

  • GraphAlgorithmsTests: 6 passed
  • GraphTraverserTests: 137 passed
  • ModuleMapMapperTests: 5 passed
  • StaticProductsGraphLinterTests: 24 passed
  • SwiftModulesBuilderTests: 2 passed
  • Total: 174 tests passed with no failures

Test environment:

  • macOS 26.5.2
  • Apple Silicon (arm64)
  • Swift 6.2.4

The new long-chain tests exercise graph depths that previously risked overflowing the process stack. Smaller characterization tests verify that the iterative implementations preserve relevant results such as traversal order, transitive dependencies, metadata, conditions, platform propagation, caching behavior, and lint warnings.

End-to-end validation

I adapted the standalone fixture generator used for the related Tuist fix so that it can generate and validate the same large dependency graph using Geko manifests:

igooor-bb/tuist-large-dag-repro

The fixture generates a large valid DAG that is intentionally closer to a real large workspace than a single linked list: multiple projects, feature layers, shared infrastructure, bridge targets with high fan-in, and no dependency cycles.

Primary Geko validation command:

GEKO_BIN=/path/to/geko ./scripts/reproduce.sh stress --tool geko --no-focus

Focused-target validation path:

GEKO_BIN=/path/to/geko ./scripts/reproduce.sh stress --tool geko --focus

For more details about the fixture structure, available presets, and reproduction commands, see the fixture repository README.

Actual result:

  • the stress fixture is generated and validated successfully;
  • the patched geko generate completes successfully on the generated workspace;
  • no stack overflow occurs in the affected graph-processing paths.

Screenshots (if appropriate):

Not applicable.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

@igooor-bb
igooor-bb requested a review from a team July 29, 2026 15:37
@igooor-bb
igooor-bb force-pushed the fix-graph-recursion-overflow branch from e8e44df to d633697 Compare August 4, 2026 14:57
Comment on lines -371 to -384
if visitedNodes[dependencyPath] != nil { return }
guard let graphDependency = graph.xcframeworks[dependencyPath] else { return }
let directDependencies = graph.dependencies[graphDependency] ?? []
let transitiveDependencies = directDependencies.reduce(into: Set<GraphDependency>()) { acc, graphDependency in
if case let .xcframework(xcframework) = graphDependency {
transitiveXCFrameworkDependencies(
dependencyPath: xcframework.path,
graph: graph,
visitedNodes: &visitedNodes
guard
visitedNodes[dependencyPath] == nil,
let graphDependency = graph.xcframeworks[dependencyPath]
else {
return
}

let rootDependencies = Array(graph.dependencies[graphDependency] ?? [])
var activePaths = Set([dependencyPath])
var stack = [
XCFrameworkDependenciesFrame(
path: dependencyPath,
dependency: graphDependency,
directDependencies: rootDependencies,
nextDependencyIndex: 0,
result: Set(rootDependencies)
),
]

while !stack.isEmpty {
let frameIndex = stack.count - 1
let frame = stack[frameIndex]

if frame.nextDependencyIndex < frame.directDependencies.count {
let dependency = frame.directDependencies[frame.nextDependencyIndex]
guard case let .xcframework(xcframework) = dependency else {
stack[frameIndex].nextDependencyIndex += 1
continue
}

if let cachedDependencies = visitedNodes[xcframework.path]?.1 {
stack[frameIndex].nextDependencyIndex += 1
stack[frameIndex].result.formUnion(cachedDependencies)
continue
}

if activePaths.contains(xcframework.path) {
stack[frameIndex].nextDependencyIndex += 1
continue
}

guard let childDependency = graph.xcframeworks[xcframework.path] else {
stack[frameIndex].nextDependencyIndex += 1
continue
}

let childDependencies = Array(graph.dependencies[childDependency] ?? [])
activePaths.insert(xcframework.path)
stack.append(
XCFrameworkDependenciesFrame(
path: xcframework.path,
dependency: childDependency,
directDependencies: childDependencies,
nextDependencyIndex: 0,
result: Set(childDependencies)
)
)
acc.formUnion(visitedNodes[xcframework.path]?.1 ?? [])
} else {
let completedFrame = stack.removeLast()
visitedNodes[completedFrame.path] = (
completedFrame.dependency,
completedFrame.result
)
activePaths.remove(completedFrame.path)

if let parentFrameIndex = stack.indices.last {
stack[parentFrameIndex].nextDependencyIndex += 1
stack[parentFrameIndex].result.formUnion(completedFrame.result)
}
}
}
visitedNodes[dependencyPath] = (graphDependency, directDependencies.union(transitiveDependencies))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi and thank you for contribution!

It seems to me, that it is pretty redundant to track next dependency index. There is no need to traverse dependencies in the same order. In fact, it is enough to just add dependencies into the stack as is, while keeping parent. In such case, dependencies will be traversed from last to first, which is fine. There still will be need to track that each dependency of current node is traversed, but in this case a simple boolean will be sufficient.

The code might look something like this

    private func transitiveXCFrameworkDependencies(
        dependencyPath: AbsolutePath,
        graph: Graph,
        visitedNodes: inout [AbsolutePath: (GraphDependency, Set<GraphDependency>)]
    ) {
        if visitedNodes[dependencyPath] != nil { return }
        guard let graphDependency = graph.xcframeworks[dependencyPath] else { return }

        var stack: [(
            dependencyPath: AbsolutePath,
            graphDependency: GraphDependency,
            dependencies: Set<GraphDependency>,
            preOrderVisit: Bool
        )] = [
            (dependencyPath, graphDependency, graph.dependencies[graphDependency] ?? [], true)
        ]

        while !stack.isEmpty {
            let dependencyPath = stack[stack.count - 1].dependencyPath
            let graphDependency = stack[stack.count - 1].graphDependency
            let directDependencies = stack[stack.count - 1].dependencies

            if stack[stack.count - 1].preOrderVisit {
                guard visitedNodes[dependencyPath] == nil else {
                    stack.removeLast()
                    continue
                }

                stack[stack.count - 1].preOrderVisit = false

                for dependency in directDependencies {
                    guard case let .xcframework(xcframework) = dependency else { continue }
                    stack.append((xcframework.path, dependency, graph.dependencies[dependency] ?? [], false))
                }
            } else {
                visitedNodes[dependencyPath] = (graphDependency, [])
                for case let .xcframework(xcframework) in directDependencies {
                    visitedNodes[dependencyPath]!.1.formUnion(
                        visitedNodes[xcframework.path]?.1 ?? []
                    )
                }

                stack.removeLast()
            }
        }
    }

The code becomes much more concise and readable. Could you please reiterate your changes using this idea?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds reasonable :) I wanted to keep the original semantics, but it's really not necessary here. I'll do it as suggested, thank you

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

I decided to keep GraphAlgorithms.swift as-is, because these algorithms rely on the exact DFS order and current path for sorting and cycle detection

@igooor-bb
igooor-bb requested a review from shinxey August 7, 2026 15:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants