Environment
- SonarQube Server:
26.4.0.121862 - SonarJava plugin:
8.27.0.43088 - Java:
21.0.8 - Build: Maven
- Rule:
java:S2259
The original project uses Spring Boot 4.1.0 and Spring Framework 7.0.8, but the issue is reproducible with a standalone Java class and does not require Spring.
Summary
java:S2259 reports that a non-null catch parameter may be null after it is passed to a helper method that traverses the exception cause chain.
The helper only reassigns a local cursor variable while calling Throwable.getCause(). When the cursor eventually becomes null at the end of the cause chain, the analyzer appears to propagate that nullable state back to the method argument and then to the original catch parameter.
Reassigning a local reference does not modify other local variables that previously contained the same reference. Therefore, the catch parameter remains non-null.
Original control flow
The relevant code follows this pattern:
private void dispatchPlanRun(UUID runId) {
try {
// Dispatch work
} catch (RuntimeException exception) {
handlePlanDispatchFailure(runId, exception);
}
}
private void handlePlanDispatchFailure(
UUID runId,
RuntimeException exception
) {
if (exception instanceof PlanDispatchOwnershipLostException) {
return;
}
boolean transientFailure =
hasCause(exception, TransientDataAccessException.class);
transactionTemplate.executeWithoutResult(status -> {
CrawlPlanRunEntity run =
planRunRepository.findLockedById(runId).orElse(null);
if (run == null
|| run.isDispatchCompleted()
|| !workerId.equals(run.getDispatchOwner())) {
return;
}
run.setLastError(truncate(exception.getMessage(), 2048));
// Additional database updates
});
observationService.event(
"plan_dispatch_failed",
workerId,
null,
"Plan dispatch failed.",
Map.of(
"planRunId", runId.toString(),
"error",
exception.getMessage() == null
? exception.getClass().getName()
: exception.getMessage(),
"transientFailure",
transientFailure
)
);
}
private boolean hasCause(
Throwable throwable,
Class<? extends Throwable> type
) {
for (
Throwable current = throwable;
current != null;
current = current.getCause()
) {
if (type.isInstance(current)) {
return true;
}
}
return false;
}
Minimal reproducible example
public class S2259CatchCauseTraversal {
static void caller() {
try {
throw new RuntimeException("failure");
} catch (RuntimeException exception) {
handle(exception);
}
}
static void handle(RuntimeException exception) {
hasCause(exception, IllegalStateException.class);
String message =
exception.getMessage() == null
? exception.getClass().getName()
: exception.getMessage();
System.out.println(message);
}
static boolean hasCause(
Throwable throwable,
Class<? extends Throwable> type
) {
for (
Throwable current = throwable;
current != null;
current = current.getCause()
) {
if (type.isInstance(current)) {
return true;
}
}
return false;
}
}
The standalone example reproduces the issue without Spring dependencies.
Actual behavior
java:S2259 reports the following on a subsequent dereference of exception:
A
NullPointerExceptioncould be thrown;exceptionis nullable here.
The displayed issue flow effectively indicates:
exceptionis passed tohasCause(...).- The
throwableparameter initially has the same reference value asexception. - The local variable
currentis initialized fromthrowable. currentmay become null after repeatedly evaluatingcurrent.getCause().- The original
exceptionreference is then treated as nullable when it is dereferenced afterhasCause(...)returns.
Expected behavior
No issue should be reported.
When control enters the catch block, the catch parameter is non-null. Evaluating throw null does not create a catch parameter containing null; it causes a NullPointerException object to be thrown.
Inside hasCause(...), the following variables are separate local variables:
exception
throwable
current
They may initially contain references to the same object, but reassigning one variable does not modify the others.
In particular:
current = current.getCause();
only changes the reference stored in current.
At the end of the cause chain, current becomes null. This does not make throwable null, and it does not make the caller’s exception variable null.
After hasCause(...) returns, dereferences such as the following are therefore safe:
exception.getMessage();
exception.getClass();
The analyzer should distinguish between:
current == null, meaning that the end of the cause chain was reached;throwable == null;exception == null.
Only the local cause-chain cursor can become null in this code.
Suspected cause
Based on the displayed issue flow, this may be caused by symbolic-state or alias information being merged incorrectly.
current, throwable, and exception initially refer to the same object. The analyzer may then be associating the later null state of current with the other references, even though current has been independently reassigned.
In Java, variables holding references are passed and assigned by value. Reassigning a local alias does not update other variables that previously held the same reference.
Questions
- Can you confirm whether this is a false positive in the symbolic-execution analysis used by
java:S2259? - Is this a known SonarJava limitation or an existing issue involving alias tracking during
Throwable.getCause()traversal?
Relevant Java documentation
-
JLS 14.17, The
throwStatement:
Chapter 14. Blocks, Statements, and Patterns -
Throwable.getCause():
Throwable (Java SE 21 & JDK 21)

