openrewrite / openrewrite/rewrite-analysis

StackOverflowError: unbounded mutual recursion between the argument→select and select→argument steps in ForwardFlow.computeVariableAssignment

Open
#113 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Java
Stars
16
Forks
12
Avg merge
2h 26m
Merged PRs (30d)
1

Description

What version of OpenRewrite are you using?

Reproduced against org.openrewrite.meta:rewrite-analysis:2.38.0-SNAPSHOT (the stack trace line numbers below match that build exactly).

How are you running OpenRewrite?

Via org.openrewrite.staticanalysis.ReplaceStackWithDeque, which uses FindLocalFlowPaths with a plain DataFlowSpec.

What is the smallest, simplest way to reproduce the problem?
import java.util.Optional;
import java.util.Stack;

class Test {
    void test(Optional<String> opt) {
        Stack<Integer> stack = new Stack<>();
        String a = "x";
        String s = Optional.ofNullable(a).orElse(opt.get());
    }
}

(The Stack is only there to satisfy the recipe's UsesType precondition; the overflow happens while computing flow out of a.)

What did you expect to see?

The recipe completes.

What did you see instead?

java.lang.StackOverflowError, with ~1000 frames alternating between exactly two call sites in ForwardFlow.computeVariableAssignment:

java.lang.StackOverflowError
  ...
  org.openrewrite.Cursor.getParentTreeCursor(Cursor.java:253)
  org.openrewrite.analysis.trait.expr.VarAccessBase.viewOf(VarAccess.java:221)
  org.openrewrite.analysis.trait.expr.VarAccess$Factory.viewOf(VarAccess.java:86)
  org.openrewrite.analysis.trait.TraitFactory.findFirstViewOf(TraitFactory.java:87)
  org.openrewrite.analysis.trait.TraitFactory.lambda$findFirstViewOf$2(TraitFactory.java:96)
  fj.data.Validation$FailProjection.bind(Validation.java:1249)
  org.openrewrite.analysis.trait.TraitFactory.findFirstViewOf(TraitFactory.java:95)
  org.openrewrite.analysis.trait.TraitFactory.findFirstViewOf(TraitFactory.java:69)
  org.openrewrite.analysis.trait.expr.Expr$Factory.viewOf(Expr.java:37)
  org.openrewrite.analysis.trait.expr.Expr.viewOf(Expr.java:51)
  org.openrewrite.analysis.dataflow.DataFlowNode.of(DataFlowNode.java:63)
  org.openrewrite.analysis.dataflow.analysis.ForwardFlow.computeVariableAssignment(ForwardFlow.java:458)
  org.openrewrite.analysis.dataflow.analysis.ForwardFlow.computeVariableAssignment(ForwardFlow.java:495)
  org.openrewrite.analysis.dataflow.analysis.ForwardFlow.computeVariableAssignment(ForwardFlow.java:466)
  org.openrewrite.analysis.dataflow.analysis.ForwardFlow.computeVariableAssignment(ForwardFlow.java:495)
  org.openrewrite.analysis.dataflow.analysis.ForwardFlow.computeVariableAssignment(ForwardFlow.java:466)
  ... (502 frames at :466, 501 frames at :495)
Root cause

computeVariableAssignment has two mutually recursive descents into siblings of the same J.MethodInvocation, with no visited set and no depth bound:

  • ForwardFlow.java:455-472 — "flow from an argument to the select", recursing on the select cursor (line 466).
  • ForwardFlow.java:477-503 — "flow from the select (or another argument) to an argument", recursing on the argument cursor (line 495).

If spec.isFlowStep(..) holds in both directions for one call, the two branches call each other forever: arg -> select -> arg -> select -> ....

For Optional.ofNullable(a).orElse(opt.get()) both directions hold, and neither comes from a bidirectional model — they come from two different ReturnValue model groups that Optimizer.optimize merges into name-indexed union matchers:

  • flowFromArgumentIndexToReturn[0] (models with Argument[0] -> ReturnValue, which includes Optional.orElse and Optional.ofNullable). Its predicate is sinkNode is a Call matching M && isParameter(srcNode, 0). With src = opt.get() (argument 0 of orElse) and sink = Optional.ofNullable(a) (a Call, and ofNullable is in the group) this is a flow step — so the arg -> select branch fires.
  • flowFromArgumentIndexToReturn[-1] (models with Argument[this] -> ReturnValue, which includes Optional.orElse and Optional.get). Its predicate is sinkNode is a Call matching M && isSelect(srcNode). With src = Optional.ofNullable(a) (the select of orElse) and sink = opt.get() (a Call, and get is in the group) this is also a flow step — so the select -> arg branch fires.

So the trigger is: a call whose select is itself a call and whose argument is itself a call, where the outer method appears in both the Argument[k] -> ReturnValue and Argument[this] -> ReturnValue value-model groups. Optional.orElse is one such method; there are others.

The forFlowFromArgumentIndexToQualifier optimizer already asserts "Argument[-1] is the 'select' or 'qualifier' of a method call. Flow would be cyclic.", so the cycle hazard is known — it just isn't guarded on this path.

Where this shows up in the wild

org.openrewrite.staticanalysis.ReplaceStackWithDeque on spring-projects/spring-data-mongodb, file
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoExampleMapper.java.

Recurring since at least 2026-08-17 in the "Java best practices" and "Common static analysis issues" flagship runs on dev, against ALL, Open Source, Netflix + Spring + Apache and Netflix + Spring. Confirmed present in run 20260825041417-SSuzc (org.openrewrite.table.SourcesFileErrors names org.openrewrite.staticanalysis.ReplaceStackWithDeque / java.lang.StackOverflowError with the trace above) and absent on 2026-08-26 — consistent with the trigger depending on which expressions in the file get full type attribution in a given LST build, not on anything about the Stack itself.

Suggested fix

Guard the mutual recursion — e.g. carry a set of already-visited cursors (or the FlowGraph nodes already on the path) through computeVariableAssignment and skip a recursive descent into a node that is already on the current path.

Note that a caller cannot work around this: DataFlowSpec.isFlowStep is final, so a spec cannot suppress the external models that create the cycle.

/cc a recipe-side mitigation is in openrewrite/rewrite-static-analysis (only run the analysis for variables the recipe can actually rewrite), but that only narrows the blast radius; any spec whose source flows into such an expression still overflows.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in src/main/java/org/openrewrite/analysis/dataflow/analysis/ForwardFlow.java, especially computeVariableAssignment around lines 455-503, and reproduce with the Optional.ofNullable(a).orElse(opt.get()) example through ReplaceStackWithDeque. Trace the argument-to-select and select-to-argument descents and add regression coverage showing the analysis terminates without StackOverflowError. Done means the minimal reproduction and the reported MongoExampleMapper case no longer overflow.

Written by the indexing model from the issue text.

Assessment

Tech stack
java
Domain
devtools
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.