Skip to content

Improve interface of reachable method analysis and related states to prevent misuse #220

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Conversation

maximilianruesch
Copy link
Collaborator

During development on #1 a triggered analysis that is based on the ReachableMethodAnalysis trait got an eager scheduler. Turns out the ReachableMethodAnalysis trait implicitly expects to only implement triggered analyses and is not (yet) compatible with eager analyses.

It expects at least some ub on the Callers property on the entity (since that would be the only time it would be triggered), but with an eager analysis that might not be the case yet when the analysis values are computed at the same time as the call graph. The rest of the analysis actually returns a dependency on the callers property just fine, even if no callers are present:

InterimPartialResult(Set(eOptP), continuationForCallers(eOptP.ub, tacEP)),

This PR updates the ReachableMethodAnalysis to stop throwing a match exception when used with an eager analysis and adds compatibility. At the same time, some other handling of the analysis is improved to add clarity or prevent further misuse (such as throwing around null values which are discouraged in Scala).

Note

This PR does not yet update the usages of the reachable method analysis to not use the deprecated method since they can be pretty expansive. Actually, the whole deprecation is up for discussion, so a second PR changing all the usages may not be created yet until we agree on a solution (which can also be revert the changes made to the public interface).

@errt
Copy link
Collaborator

errt commented Sep 17, 2024

Code changes look reasonable, but I'm not sure what the purpose of an eager ReachableMethodsAnalysis would be. ReachableMethodsAnalysis is meant for analyses that only want to analyze methods that are definitely reachable, and that is only known once there is a respective Callers property.

@maximilianruesch
Copy link
Collaborator Author

That is true, now that I think about it. Lets look at it this way:

Initially, there is no purpose an eager ReachableMethodAnalysis could serve that a triggered one could not. But because of #112, using an eager analysis can be used as a workaround when computing properties outside of the computation of e.g. the call graph (like e.g. computing the RTACallGraphKey and then computing the string analysis from #1 in the next step).

Theoretically, we could just fix #112 and be fine. Since this code change does make sense regardless since it simply updates a class to reflect its own abilities, I'd still opt for merging this.

@errt
Copy link
Collaborator

errt commented Sep 18, 2024

Agreed, but #112 should really be fixed, using eager schedulers to work around this is merely a band aid and will not be sufficient once analysis batching is implemented.

results
)
}

/**
* @deprecated Use [[processMethod]](ContextType, Option[_]) instead
Copy link
Collaborator

Choose a reason for hiding this comment

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

Doesn't this lead to compile errors since we have warnings-as-errors active? This should produce a deprecation warning after all for the existing subclasses, right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

That is true. I now decided to, instead of deprecating it, remove it entirely since we only do major updates for now anyways and the next release is allowed to break existing analyses. During this I will make it easier for analyses to define which portions of the reachable method analysis they implement (i.e. with / without body). This also includes a refactoring of the state handling for the only analysis that processes methods without a body but still implements TACAIBasedAnalysisState regardless (which does not really make sense)...

@maximilianruesch maximilianruesch force-pushed the improvement/better-error-for-reachable-method-analysis-misuse branch from eac46e6 to ec68ef8 Compare September 18, 2024 16:08
@maximilianruesch maximilianruesch changed the title Improve misuse handling of reachable method analysis Improve interface of reachable method analysis and related states to prevent misuse Sep 18, 2024
else
otherDependees
}
abstract override def dependees: Set[SomeEOptionP] = super.dependees ++ Some(_tacDependee).filter(_.isRefinable)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I understand the wish to make this more 'functional', but I think this creates more overhead than necessary.

* @author Maximilian Rüsch
*/
class TACAIBasedCGState[ContextType <: Context](
override val callContext: ContextType,
Copy link
Collaborator

Choose a reason for hiding this comment

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

This looks weird. Does this really have to be an override val? Can't it just be a constructor parameter that is passed to the CGState constructor?

returnResult(new DirectCalls(), enforceCalleesResult = true)(state)
} else {
val state = new CGState[ContextType](callContext)
Results {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Use round parentheses

} else {
val state = new CGState[ContextType](callContext)
Results {
new DirectCalls().partialResults(state.callContext, enforceCalleesResult = true)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this actually do anything? Doesn't look like it.
I think this produces no results at all while missing what the original returnResults call would have returned: the InterimPartialResult for the state's dependees.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It does do something: Enforce a partial callees result to be produced as it would by an empty direct calls helper instance. If you look at the previous code, it was simply calling returnResult with the state and a newly instantiated helper object. Since the state cannot have a dependee since we did not provide it any (and it does not have access to the project or propertyStore), no InterimResult could be ever produced. Thus, we can specialize returnResult to use the TACAI-Based state to use in the continuation and return our result independently here.

I will make this clearer by removing the newly created state entirely, since we only use the callContext from it anyways.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh, right, I didn't see that.

else
returnResult(new DirectCalls(), enforceCalleesResult = true)(state)
} else {
val state = new CGState[ContextType](callContext)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Having two different kinds of state here looks strange. If the code below actually did anything (see other comment), I'd expect this to fail in the continuation, because the continuation would get an unexpected kind of state.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Since there is no continuation (see #220 (comment)), it cannot fail. I will remove the state entirely (see the other comment again).

// the method is reachable, so we analyze it!

if (callersEOptP.isFinal && callersEOptP.ub == NoCallers) {
return NoResult;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Shouldn't we keep the check for an illegal NoCallers interim state at least as an assertion? Right now it is not clearly visible that the method is reachable and should be analyzed.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The NoCallers interim state was illegal since the code at the top did not know the class could use the continuation without a depending caller. Since the class can invoke and handle continuations just fine without using a depender constructed from a caller, the NoCallers interim state is not illegal anymore.

Copy link
Collaborator

Choose a reason for hiding this comment

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

But it's an illegal state in general, isn't it? I don't think call graph analyses are supposed to ever create such an interim state.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If it is an illegal state in general, then it should be asserted by the call graph generating framework and not the framework for analysis that use the call graph (even if they at least partially overlap). This class seems like the wrong area of code to make such an assertion (e.g. it would only be triggered when using such an analysis even though the result should be illegal regardless of using this class or not).

): ProperPropertyComputationResult = {
val newCallers = if (eOptP.hasUBP) eOptP.ub else NoCallers
Copy link
Collaborator

Choose a reason for hiding this comment

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

Wouldn't it be more sensible to turn this into an if, only executing the forNewCalleeContexts if there is a UBP?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Since we need the newCallers for constructing a continuation and forNewCalleeContexts does not do anything when NoCallers is given, I view this as reasonable and less verbose as well.

tacEP: EPS[Method, TACAI]
eOptP: EOptionP[DeclaredMethod, Callers],
oldCallers: Callers,
tacEPOpt: Option[EPS[Method, TACAI]]
Copy link
Collaborator

Choose a reason for hiding this comment

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

An EPS wrapped into an Option looks wrong. EPS is already a subtype of EOptionP which captures the possibility of no value being present.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It kind of depends on whether you understand EPK as an interim / refinable state (which it says it is since the class expresses it is not final) that may produce a value if awaited via a continuation OR you understand it as a simple "there is no property here and there will never be one".

Since my understanding of the class is the former, I think an Option[EPS] is more reasonable than an EOptionP here.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Well, EPK is also used for queries, just representing a pair of entity and propertykey as the name suggest.

Copy link
Collaborator Author

@maximilianruesch maximilianruesch Sep 19, 2024

Choose a reason for hiding this comment

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

I tried to use an EPK, but there is a problem: Since the processMethodWithoutBody is also invoked when no single defined method exists, there may be no Method to derive from the given DeclaredMethod with which you would create an EPK[Method, TACAI]. See https://github.com/opalj/opal/actions/runs/10939312309/job/30369296437?pr=220.

So we probably have to go back to using an Option, be it Option[EPS] or Option[EOptionP]. Or do you have another idea?


trait DefinedBodyReachableMethodAnalysis extends ReachableMethodAnalysis {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Since there seems to be only one ReachableMethodAnalysis that does process methods without body, might it be more sensible to switch the logic around, with the base trait not doing that and the specialized trait doing it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I pondered this and did could not answer this question. On one hand, if the specialized trait is used far more than the base trait, something might be wrong. Additionally, the specialized trait right now restricts the abilities of the base trait (if you want to view "does not process but just returns no result" as a restriction) which feels a bit against the core principles of inheritance.

On the other hand, even if the base trait does not handle unknown bodies, it still needs to define extension points for

  1. The unknown body specialised trait to handle such unknown bodies
  2. A processMethod or similar that has an EOptionP as a parameter.

In short (using the flipped version): If you only extend the ReachableMethodAnalysis you should only need to implement the EPS version of processMethod since you know you have a body. If you extend the UnknownBodyReachableMethodAnalysis, you should only need to implement the EOptionP version of processMethod which can handle both scenarios.

All in all, I feel more comfortable with the current solution, but I am happy to replace it with a better one if you have some ideas.

@maximilianruesch maximilianruesch force-pushed the improvement/better-error-for-reachable-method-analysis-misuse branch from ec68ef8 to 30bec19 Compare September 19, 2024 10:37
@errt errt force-pushed the improvement/better-error-for-reachable-method-analysis-misuse branch 4 times, most recently from b54a2d5 to 61fa846 Compare June 4, 2025 12:15
@errt errt force-pushed the improvement/better-error-for-reachable-method-analysis-misuse branch from 61fa846 to 2c20434 Compare June 4, 2025 12:20
@errt errt requested a review from johannesduesing June 4, 2025 12:31
@errt
Copy link
Collaborator

errt commented Jun 4, 2025

I went over this PR and tried several options, none satisfactory. I ended up moving the null based handling of methods without bodies into the only analysis that actually uses it. This avoids having Option[EOptionP[...]] or EPKs that will never be computed. It still uses null, but not as part of the interface of ReachableMethodAnalysis, but only internally in CallGraphAnalysis. This seems the cleanest solution for me for now, but please voice your opinions on this.

@maximilianruesch
Copy link
Collaborator Author

I think moving the null handling into the CallGraphAnalysis is a good idea. In the current version however, the null value still traverses into code from the ReachableMethodsAnalysis trait and might cause unexpected behaviour. Lets take your idea one step further.

Surprisingly, with slightly reshuffling processing of methods, the API that needs adaptation for new analyses to support methods without bodies is now as close to the "with-body" analogy as can be: The same interface, simply without a TAC EP.
Additionally, this has the benefit of removing the null value invocation alltogether. I have attached a sample patch (inline, since uploading .patch files from Linux is currently not supported on GitHub), so please have a look. If youd like me to push it, ill be happy to.

Subject: [PATCH] Reduce null escape surface in reachable method analysis
---
Index: OPAL/tac/src/main/scala/org/opalj/tac/fpcf/analyses/cg/CallGraphAnalysis.scala
<+>UTF-8
===================================================================
diff --git a/OPAL/tac/src/main/scala/org/opalj/tac/fpcf/analyses/cg/CallGraphAnalysis.scala b/OPAL/tac/src/main/scala/org/opalj/tac/fpcf/analyses/cg/CallGraphAnalysis.scala
--- a/OPAL/tac/src/main/scala/org/opalj/tac/fpcf/analyses/cg/CallGraphAnalysis.scala	(revision 2c2043406aa0cd849b07a9a46c2329adeeff7bcd)
+++ b/OPAL/tac/src/main/scala/org/opalj/tac/fpcf/analyses/cg/CallGraphAnalysis.scala	(revision edaf1d76c78dc223a4e2d06cdc8f4c95eb2d3c19)
@@ -5,7 +5,6 @@
 package analyses
 package cg
 
-import org.opalj.br.DeclaredMethod
 import org.opalj.br.Method
 import org.opalj.br.MethodDescriptor
 import org.opalj.br.ObjectType
@@ -21,7 +20,6 @@
 import org.opalj.br.fpcf.properties.cg.Callers
 import org.opalj.br.fpcf.properties.cg.OnlyCallersWithUnknownContext
 import org.opalj.fpcf.Entity
-import org.opalj.fpcf.EOptionP
 import org.opalj.fpcf.EPK
 import org.opalj.fpcf.EPS
 import org.opalj.fpcf.InterimEUBP
@@ -29,7 +27,6 @@
 import org.opalj.fpcf.InterimUBP
 import org.opalj.fpcf.ProperPropertyComputationResult
 import org.opalj.fpcf.PropertyBounds
-import org.opalj.fpcf.PropertyComputationResult
 import org.opalj.fpcf.PropertyKind
 import org.opalj.fpcf.PropertyStore
 import org.opalj.fpcf.Results
@@ -121,20 +
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP118,16 @@
         }
     }
 
-    override final def processMethodWithoutBody(eOptP: EOptionP[DeclaredMethod, Callers]): PropertyComputationResult = {
-        processMethodWithoutBody(eOptP, null)
+    override final def processMethodWithoutBody(callContext: ContextType): ProperPropertyComputationResult = {
+        Results(new DirectCalls().partialResults(callContext, enforceCalleesResult = true))
     }
 
     override final def processMethod(
         callContext: ContextType,
         tacEP:       EPS[Method, TACAI]
     ): ProperPropertyComputationResult = {
-        if (tacEP ne null) {
-            val state = new TACAIBasedCGState[ContextType](callContext, tacEP)
-            processMethod(state, new DirectCalls())
-        } else {
-            Results(new DirectCalls().partialResults(callContext, enforceCalleesResult = true))
-        }
+        val state = new TACAIBasedCGState[ContextType](callContext, tacEP)
+        processMethod(state, new DirectCalls())
     }
 
     protected[this] def doHandleVirtualCall(
Index: OPAL/tac/src/main/scala/org/opalj/tac/fpcf/analyses/cg/ReachableMethodAnalysis.scala
<+>UTF-8
===================================================================
diff --git a/OPAL/tac/src/main/scala/org/opalj/tac/fpcf/analyses/cg/ReachableMethodAnalysis.scala b/OPAL/tac/src/main/scala/org/opalj/tac/fpcf/analyses/cg/ReachableMethodAnalysis.scala
--- a/OPAL/tac/src/main/scala/org/opalj/tac/fpcf/analyses/cg/ReachableMethodAnalysis.scala	(revision 2c2043406aa0cd849b07a9a46c2329adeeff7bcd)
+++ b/OPAL/tac/src/main/scala/org/opalj/tac/fpcf/analyses/cg/ReachableMethodAnalysis.scala	(revision edaf1d76c78dc223a4e2d06cdc8f4c95eb2d3c19)
@@ -27,7 +27,7 @@
  * Base trait for analyses that are executed for every method that is reachable.
  * The analysis is performed by `processMethod`.
  *
- * Note that methods without a body are not processed unless `processMethodWithoutBody` is overridden
+ * Note that methods without a body are not processed unless `processMethodWithoutBody` is overridden.
  *
  * @author Florian Kuebler
  */
@@ -44,7 +44,7 @@
 
         // we only allow defined methods
         if (!declaredMethod.hasSingleDefinedMethod)
-            return processMethodWithoutBody(callersEOptP);
+            return processNewContexts(callersEOptP, NoCallers) { processMethodWithoutBody };
 
         val method = declaredMethod.definedMethod
 
@@ -52,34 +52,31 @@
         if (method.classFile.thisType != declaredMethod.declaringClassType)
             return NoResult;
 
-        if (method.body.isEmpty)
+        if (method.body.isEmpty) {
             // happens in particular for native methods
-            return processMethodWithoutBody(callersEOptP);
+            return processNewContexts(callersEOptP, NoCallers) { processMethodWithoutBody };
+        };
 
         val tacEP = propertyStore(method, TACAI.key)
         if (tacEP.hasUBP && tacEP.ub.tac.isDefined) {
-            processMethod(callersEOptP, NoCallers, tacEP.asEPS)
+            processMethodWithTAC(callersEOptP, NoCallers, tacEP.asEPS)
         } else {
             InterimPartialResult(Set(tacEP), continuationForTAC(declaredMethod))
         }
     }
 
-    protected def processMethodWithoutBody(
-        eOptP: EOptionP[DeclaredMethod, Callers]
-    ): PropertyComputationResult = NoResult
-
-    protected def processMethodWithoutBody(
-        eOptP: EOptionP[DeclaredMethod, Callers],
-        tacEP: EPS[Method, TACAI]
+    private def processMethodWithTAC(
+        eOptP:      EOptionP[DeclaredMethod, Callers],
+        oldCallers: Callers,
+        tacEP:      EPS[Method, TACAI]
     ): ProperPropertyComputationResult = {
-        processMethod(eOptP, NoCallers, tacEP)
+        processNewContexts(eOptP, oldCallers) { processMethod(_, tacEP) }
     }
 
-    protected def processMethod(
+    private def processNewContexts(
         eOptP:      EOptionP[DeclaredMethod, Callers],
         oldCallers: Callers,
-        tacEP:      EPS[Method, TACAI]
-    ): ProperPropertyComputationResult = {
+    )(processMethod: ContextType => PropertyComputationResult): ProperPropertyComputationResult = {
         val newCallers = if (eOptP.hasUBP) eOptP.ub else NoCallers
         var results: List[ProperPropertyComputationResult] = Nil
 
@@ -87,15 +84,19 @@
             val theCalleeContext =
                 if (calleeContext.hasContext) calleeContext.asInstanceOf[ContextType]
                 else typeIterator.newContext(eOptP.e)
-            results ::= processMethod(theCalleeContext, tacEP)
+            results ::= processMethod(theCalleeContext)
         }
 
         Results(
-            InterimPartialResult(Set(eOptP), continuationForCallers(newCallers, tacEP)),
+            InterimPartialResult(Set(eOptP), continuationForCallers(processNewContexts(_, newCallers)(processMethod))),
             results
         )
     }
 
+    protected def processMethodWithoutBody(
+        callContext: ContextType,
+    ): PropertyComputationResult = NoResult
+
     def processMethod(
         callContext: ContextType,
         tacEP:       EPS[Method, TACAI]
@@ -106,7 +107,7 @@
     )(someEPS: SomeEPS): ProperPropertyComputationResult = {
         someEPS match {
             case UBP(tac: TACAI) if tac.tac.isDefined =>
-                processMethod(
+                processMethodWithTAC(
                     propertyStore(declaredMethod, Callers.key),
                     NoCallers,
                     someEPS.asInstanceOf[EPS[Method, TACAI]]
@@ -117,12 +118,8 @@
     }
 
     private[this] def continuationForCallers(
-        oldCallers: Callers,
-        tacEP:      EPS[Method, TACAI]
-    )(
-        update: SomeEPS
-    ): ProperPropertyComputationResult = {
-        val newCallers = update.asInstanceOf[EPS[DeclaredMethod, Callers]]
-        processMethod(newCallers, oldCallers, tacEP)
+        processMethod: EOptionP[DeclaredMethod, Callers] => ProperPropertyComputationResult
+    )(someEPS: SomeEPS): ProperPropertyComputationResult = {
+        processMethod(someEPS.asInstanceOf[EPS[DeclaredMethod, Callers]])
     }
 }

@errt
Copy link
Collaborator

errt commented Jun 5, 2025

@maximilianruesch Thank you, this looks sensible. I was looking for a way to reuse the method that you now named processNewContexts, but didn't find a suitable way. You're right that it works nicely with the closure. You can push the change if you'd like to.

@maximilianruesch maximilianruesch force-pushed the improvement/better-error-for-reachable-method-analysis-misuse branch from 74e697b to 5a02d44 Compare June 5, 2025 12:50
@maximilianruesch
Copy link
Collaborator Author

I had to adjust the return type of the processMethodWithoutBody slightly to be able to properly assert when it does not return a real result (since Results only accepts lists of actual results, i.e. NoResult is not allowed).

As an alternative to the current solution with Option[ProperPropertyComputationResult] we could revert to PropertyComputationResult and instead introduce isProperResult (or similar) on the latter to allow for testing if an object is NoResult (without an instance check). Let me know what you think.

Otherwise, this PR should be good to go!

@maximilianruesch maximilianruesch requested review from errt and removed request for johannesduesing June 5, 2025 13:01
@errt
Copy link
Collaborator

errt commented Jun 5, 2025

It would probably be simpler to to just return something that is a ProperPropertyComputationResult but does nothing. E.g. an empty Results or InterimPartialResult.

@maximilianruesch maximilianruesch force-pushed the improvement/better-error-for-reachable-method-analysis-misuse branch from 5a02d44 to 6742c9b Compare June 5, 2025 13:12
errt
errt previously approved these changes Jun 10, 2025
Copy link
Collaborator

@errt errt left a comment

Choose a reason for hiding this comment

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

LGTM now

…thod-analysis-misuse

# Conflicts:
#	OPAL/tac/src/main/scala/org/opalj/tac/fpcf/analyses/cg/reflection/ReflectionRelatedCallsAnalysis.scala
@maximilianruesch maximilianruesch added this pull request to the merge queue Jun 12, 2025
Merged via the queue into develop with commit bce77ba Jun 12, 2025
2 checks passed
@errt errt deleted the improvement/better-error-for-reachable-method-analysis-misuse branch June 12, 2025 08:25
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