ADFA-4329: Guard module-graph traversal against dependency cycles (StackOverflow)#1408
ADFA-4329: Guard module-graph traversal against dependency cycles (StackOverflow)#1408fryanpan wants to merge 5 commits into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
📝 WalkthroughRelease NotesBug Fixes
Improvements
Testing
|
| Layer / File(s) | Summary |
|---|---|
ModuleProject: cycle helpers and updated abstract API subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/ModuleProject.kt |
Adds formatDependencyCycle and reportDependencyCycle helpers; changes abstract getCompileClasspaths and getCompileModuleProjects signatures to require visited/recursionPath; adds concrete no-arg wrappers that seed those parameters before delegating to the abstract implementations. |
AndroidModule: cycle-safe classpath and module traversal subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/AndroidModule.kt |
getCompileClasspaths checks visited and returns empty on re-entry; collectLibraries threads per-module visited and cross-module moduleVisited sets, recursing into getCompileClasspaths(..., moduleVisited) for PROJECT dependencies; getCompileModuleProjects uses recursionPath.contains for true-cycle detection and visited.add for diamond deduplication with a try/finally removeLast pattern. |
JavaModule: cycle-safe classpath and module traversal subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/JavaModule.kt |
getCompileClasspaths guards against revisiting module paths and propagates visited into compile-scope module recursive calls; getCompileModuleProjects adds recursionPath cycle detection and visited deduplication matching the AndroidModule pattern. |
Cycle and diamond termination tests subprojects/projects/src/test/java/com/itsaky/androidide/projects/api/CompileModuleProjectsCycleTest.kt |
New Robolectric test suite with helpers to construct AndroidModule instances and install a ProjectManagerImpl workspace; covers two-node cycles, self-dependency, longer cycles, diamond shared dependencies, getCompileClasspaths termination, and formatDependencyCycle output rendering. |
Sequence Diagram(s)
sequenceDiagram
participant Caller
participant ModuleProject
participant AndroidModule
Caller->>ModuleProject: getCompileModuleProjects()
ModuleProject->>ModuleProject: visited = mutableSetOf()<br/>recursionPath = ArrayDeque()
ModuleProject->>AndroidModule: getCompileModuleProjects(visited, recursionPath)
rect rgba(255, 100, 100, 0.5)
note over AndroidModule: Cycle check
AndroidModule->>AndroidModule: recursionPath.contains(path)?
AndroidModule-->>ModuleProject: reportDependencyCycle() → return emptyList()
end
rect rgba(100, 200, 100, 0.5)
note over AndroidModule: Diamond dedup
AndroidModule->>AndroidModule: visited.add(path) returns false → skip
end
AndroidModule->>AndroidModule: recursionPath.addLast(path)
AndroidModule->>AndroidModule: recurse into dependencies
AndroidModule->>AndroidModule: recursionPath.removeLast() [finally]
AndroidModule-->>Caller: List<ModuleProject>
Estimated code review effort
🎯 3 (Moderate) | ⏱️ ~25 minutes
Suggested reviewers
- Daniel-ADFA
Poem
🐇 Hop, hop — no infinite loops today!
The visited set keeps cycles at bay,
:a -> :b -> :a? We see through your trick!
recursionPathcatches it, slick as a click.
Diamond deps pass, no false alarm cried —
The bunny lands safe on the other side! 🌿
🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
| Check name | Status | Explanation | Resolution |
|---|---|---|---|
| Docstring Coverage | Docstring coverage is 59.26% which is insufficient. The required threshold is 80.00%. | Write docstrings for the functions missing them to satisfy the coverage threshold. |
✅ Passed checks (4 passed)
| Check name | Status | Explanation |
|---|---|---|
| Title check | ✅ Passed | The title clearly and specifically describes the main change: guarding module-graph traversal against dependency cycles to prevent StackOverflow errors. |
| Description check | ✅ Passed | The description comprehensively explains the issue, fix, testing approach, and user impact in relation to the changeset modifications. |
| Linked Issues check | ✅ Passed | Check skipped because no linked issues were found for this pull request. |
| Out of Scope Changes check | ✅ Passed | Check skipped because no linked issues were found for this pull request. |
✏️ Tip: You can configure your own custom pre-merge checks in the settings.
✨ Finishing Touches
📝 Generate docstrings
- Create stacked PR
- Commit on current branch
🧪 Generate unit tests (beta)
- Create PR with unit tests
- Commit unit tests in branch
ADFA-4329-getcompilemoduleprojects-stackoverflow
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands and usage tips.
Sentry APPDEVFORALL-Z0. Thread a visited set through module/library traversal to stop StackOverflow on cyclic module graphs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrite CompileModuleProjectsCycleTest to build a cyclic graph of real AndroidModule instances (backed by protobuf VariantDependencies referencing each other as Project libraries) registered in a real Workspace on the production ProjectManagerImpl, instead of a hand-written FakeModule copy of the guard. This drives the actual AndroidModule.getCompileModuleProjects traversal: it throws StackOverflowError on the pre-fix baseline and terminates with the visited-set guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep the cycle guard, but distinguish a TRUE cycle (a module already on the DFS recursion path) from a harmless diamond/shared dependency, and log an actionable chain (e.g. ':a -> :b -> :a') when a real cycle is broken — instead of silently returning emptyList and leaving the user with a confusing downstream build error. Thread an ordered recursionPath alongside the existing visited set; add ModuleProject.reportDependencyCycle + the pure, testable formatDependencyCycle. Tests: a diamond/shared dep terminates and is not mis-reported as a cycle; the formatter renders the loop from the first occurrence of the repeated module. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The StackOverflow fix only covered getCompileModuleProjects; the sibling classpath traversal had the same defect — AndroidModule.collectLibraries' cross-module 'module.getCompileClasspaths(...)' (and JavaModule's per-dependency call) recursed a cyclic PROJECT graph with no module-level visited set, so :a -> :b -> :a overflowed the stack on classpath resolution (e.g. LSP resolving a file's classpath), independent of getCompileModuleProjects. Thread a module-path 'visited' set through getCompileClasspaths in ModuleProject/AndroidModule/JavaModule (each module contributes its classpaths at most once; a revisit returns emptySet). The public 1-arg getCompileClasspaths is preserved (delegates with a fresh set) so external callers are unaffected. The user-facing cycle report still comes from getCompileModuleProjects; this only breaks the cycle. Tests: getCompileClasspaths terminates on two-node and self cycles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2350162 to
b9b4279
Compare
| // Note: the recursive call intentionally keeps the original behavior of not propagating | ||
| // `excludeSourceGeneratedClassPath` (it defaults to false here); only the visited-set | ||
| // cycle guard is added. | ||
| collectLibraries( | ||
| root = root, | ||
| libraries = library.dependencyList, | ||
| result = result, | ||
| excludeSourceGeneratedClassPath = false, | ||
| visited = visited, | ||
| moduleVisited = moduleVisited, | ||
| ) |
There was a problem hiding this comment.
The previous behavior was an oversight, so the excludeSourceGeneratedClassPath should be propagated here. Could you please update this so the flag is propagated?
There was a problem hiding this comment.
The newly added functions accept visited and recursionPath parameters, which seem to be something that the callers don't need to know about. They also seem to be only called internally. Can we make the newly added overloads protected?
Jira Ticket: https://appdevforall.atlassian.net/browse/ADFA-4329
Sentry Issue: https://appdevforall-inc-9p.sentry.io/issues/APPDEVFORALL-Z0
Note
This stops the crash, but only logs the dependency cycle into developer logs without showing the user anything obvious. When the user actually tries to build the project, Gradle should surface a more user friendly and visible message into the Build Output Panel.
Was reviewing with Claude if there might be an easy way to make this error more visible at this point -- but it seemed like this is happening in a layer where there isn't easy access to show something in the UI.
Reproduction Details
ModuleProject.getCompileModuleProjects()(and the siblinggetCompileClasspaths()) walk the project dependency graph and recurse with no module-level visited set. A cyclic project graph (:a → :b → :a, or a module that is its own transitive project dependency) recurses until the JVM stack overflows.Stack Trace
(transaction: EditorActivityKt · device.class: medium · Android 14)
User Steps
User steps leading up to crash, based on Sentry breadcrumbs:
BackgroundIndexer: Closing indexer with 2 active job(s)) and the project model is traversed; the opened project has a cyclic module dependency, so the recursive graph walk overflows the stack during load.Was able to reproduce in a unit test?
Yes.
CompileModuleProjectsCycleTest(:subprojects:projects) builds a cycle of realAndroidModuleinstances over protobuf project models and drives the production traversal. On the pre-fix baseline all cycle cases fail withStackOverflowError; on the branch they terminate. AddedgetCompileClasspathstwo-node + self-cycle termination tests too.What Was Fixed
visited: MutableSet<String>(module path) throughgetCompileModuleProjectsinModuleProject/AndroidModule/JavaModule; each module expands at most once. Public no-arg API delegates with a fresh set.Module dependency cycle detected: :a -> :b -> :a …) instead of silently swallowing it (new UX,ModuleProject.reportDependencyCycle+ pureformatDependencyCycle).getCompileClasspathstraversal (AndroidModule.collectLibrariescross-module call +JavaModule), which had the identical SOE on classpath resolution.Testing
:subprojects:projects:testV8DebugUnitTest --tests CompileModuleProjectsCycleTest→ 7/7 green (3 module-cycle termination, diamond-not-a-cycle, cycle-chain formatter, 2 classpath-cycle termination). Verified red→green by reverting only the prod files to baseline. Local CodeRabbit review: no findings.Fixes APPDEVFORALL-Z0