Skip to content

ADFA-4329: Guard module-graph traversal against dependency cycles (StackOverflow)#1408

Open
fryanpan wants to merge 5 commits into
stagefrom
ADFA-4329-getcompilemoduleprojects-stackoverflow
Open

ADFA-4329: Guard module-graph traversal against dependency cycles (StackOverflow)#1408
fryanpan wants to merge 5 commits into
stagefrom
ADFA-4329-getcompilemoduleprojects-stackoverflow

Conversation

@fryanpan

@fryanpan fryanpan commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

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 sibling getCompileClasspaths()) 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

StackOverflowError: stack size 1039KB
  at com.itsaky.androidide.projects.api.AndroidModule.getCompileModuleProjects(Unknown Source:107)
  at com.itsaky.androidide.projects.api.AndroidModule.getCompileModuleProjects(Unknown Source:107)
  ... (the getCompileModuleProjects frame repeats ~90× until the stack is exhausted) ...
  at com.itsaky.androidide.projects.api.AndroidModule.getCompileModuleProjects(Unknown Source:58)
  at java.util.LinkedHashMap.get(LinkedHashMap.java:554)

(transaction: EditorActivityKt · device.class: medium · Android 14)

User Steps

User steps leading up to crash, based on Sentry breadcrumbs:

  • User leaves the editor for the project list, then re-opens a project (EditorActivityKt paused → MainActivity → EditorActivityKt created→started→resumed).
  • On re-open the background indexer starts (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 real AndroidModule instances over protobuf project models and drives the production traversal. On the pre-fix baseline all cycle cases fail with StackOverflowError; on the branch they terminate. Added getCompileClasspaths two-node + self-cycle termination tests too.

What Was Fixed

  • Thread a visited: MutableSet<String> (module path) through getCompileModuleProjects in ModuleProject/AndroidModule/JavaModule; each module expands at most once. Public no-arg API delegates with a fresh set.
  • Distinguish a true cycle (module already on the DFS recursion path) from a harmless diamond/shared dependency, and report the cycle as an actionable error (Module dependency cycle detected: :a -> :b -> :a …) instead of silently swallowing it (new UX, ModuleProject.reportDependencyCycle + pure formatDependencyCycle).
  • Apply the same module-path guard to the sibling getCompileClasspaths traversal (AndroidModule.collectLibraries cross-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

@fryanpan fryanpan marked this pull request as ready for review June 19, 2026 11:45
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8b1bc877-5842-4da3-ab4e-cb69b6bd9ec1

📥 Commits

Reviewing files that changed from the base of the PR and between d73ec0b and b9b4279.

📒 Files selected for processing (4)
  • subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/AndroidModule.kt
  • subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/JavaModule.kt
  • subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/ModuleProject.kt
  • subprojects/projects/src/test/java/com/itsaky/androidide/projects/api/CompileModuleProjectsCycleTest.kt

📝 Walkthrough

Release Notes

Bug Fixes

  • Critical: Fixed StackOverflowError (APPDEVFORALL-Z0) that occurred when the IDE attempted to traverse project dependency graphs containing cycles (e.g., :a → :b → :a or self-referential cycles)
    • Root cause: ModuleProject.getCompileModuleProjects() and getCompileClasspaths() recursively walked dependency graphs without tracking visited modules
    • Solution: Thread a visited parameter through traversal methods to ensure each module is expanded at most once

Improvements

  • Enhanced cycle detection to distinguish between true cycles and harmless diamond dependencies (shared transitive dependencies)
  • Added actionable error reporting for detected dependency cycles with formatted cycle chains (e.g., :a -> :b -> :a) logged to developer logs
  • Gradle will provide a user-friendly message in the Build Output Panel during project builds

Testing

  • Added comprehensive unit test suite (CompileModuleProjectsCycleTest) with 7 test cases using real AndroidModule instances:
    • Verify two-node and longer cycles terminate without StackOverflow
    • Verify self-referential cycles are properly detected
    • Verify diamond dependencies are not misreported as cycles
    • Verify getCompileClasspaths properly terminates on cyclic module graphs
    • Verify cycle formatter correctly renders dependency chains

⚠️ Implementer Breaking Changes

  • Abstract method signature changes in ModuleProject:
    • abstract fun getCompileModuleProjects(): List<ModuleProject>abstract fun getCompileModuleProjects(visited: MutableSet<String>, recursionPath: ArrayDeque<String>): List<ModuleProject>
    • abstract fun getCompileClasspaths(excludeSourceGeneratedClassPath: Boolean): Set<File>abstract fun getCompileClasspaths(excludeSourceGeneratedClassPath: Boolean, visited: MutableSet<String>): Set<File>
    • Risk: Any custom subclasses of ModuleProject must update their implementations to match new abstract method signatures
    • Violation of best practices: Adding required parameters to abstract methods is a breaking change affecting all subclasses and third-party implementations

Backward Compatibility

  • Public caller API remains fully backward compatible:
    • Concrete wrapper methods getCompileModuleProjects() and getCompileClasspaths(excludeSourceGeneratedClassPath) remain unchanged and delegate to abstract methods with initialized parameters
    • Existing client code calling these public methods continues to work without modification
  • All internal implementations (AndroidModule, JavaModule) have been updated to match new signatures

Walkthrough

Compile classpath and module project traversal in ModuleProject, AndroidModule, and JavaModule are updated to accept visited and recursionPath parameters, enabling DFS cycle detection and diamond-deduplication. Public no-arg wrappers in ModuleProject seed these parameters. A new Robolectric test suite validates termination across all cycle shapes.

Changes

Compile Dependency Cycle Detection

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>
Loading

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!
recursionPath catches 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 ⚠️ Warning 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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

fryanpan and others added 5 commits June 19, 2026 09:58
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>
@fryanpan fryanpan force-pushed the ADFA-4329-getcompilemoduleprojects-stackoverflow branch from 2350162 to b9b4279 Compare June 19, 2026 16:58
@Daniel-ADFA Daniel-ADFA self-requested a review June 23, 2026 17:59
Comment on lines +292 to +302
// 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,
)

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.

The previous behavior was an oversight, so the excludeSourceGeneratedClassPath should be propagated here. Could you please update this so the flag is propagated?

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.

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?

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