False positive in java:S2259 after traversing a Throwable cause chain

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 NullPointerException could be thrown; exception is nullable here.

The displayed issue flow effectively indicates:

  1. exception is passed to hasCause(...).
  2. The throwable parameter initially has the same reference value as exception.
  3. The local variable current is initialized from throwable.
  4. current may become null after repeatedly evaluating current.getCause().
  5. The original exception reference is then treated as nullable when it is dereferenced after hasCause(...) 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

  1. Can you confirm whether this is a false positive in the symbolic-execution analysis used by java:S2259?
  2. Is this a known SonarJava limitation or an existing issue involving alias tracking during Throwable.getCause() traversal?

Relevant Java documentation

Hi @vxtls,

Thanks for the clear reproducer. This looks like a false positive.

Only the local current variable inside hasCause(...) should become null; that should not make the caught exception nullable afterward.

I created SONARJAVA-6668 to track it, and linked it to the older related ticket SONARJAVA-1848.

Thanks again for the report.
Erwan

Hi @vxtls !

I took another look at your example. Thank you for the very detailed report and minimal reproducer.

I can confirm that an issue is raised, but to me it appears to be a (somewhat opinionated) TP.
The reasoning is that the issue concerns only the handle(...) method. The caller(...) where the exception is raised is not part of the flow:

The flow starts at line 12 where the exception parameter is being passed to hasCause. It is not starting in the caller(...) method. At this point, no assumption is being made yet by the analyzer about whether exception is null or not.

At flow location (3) the analyzer assumes that current can be null because it is being compared against null.
During the first iteration of the loop, current is pointing to the same object as the throwable parameter, which is instantiated with the exception argument from the handle(...) caller.
I.e. effectively, the exception symbol from the caller is being compared against null which is why the analyzer assumes exception can be null from this point forward. This leads to the raised issue.

We can simplify the reproducer even more to demonstrate the logic:

public class S2259CatchCauseTraversal2 {
  static String handle(RuntimeException exception) {
    hasCause(exception); // <- analyzer learns exception could be null because this call compares it against `null`

    return exception.getMessage(); // <- Issue still raised here!
  }

  static boolean hasCause(Throwable throwable) {
    if (throwable != null) { // <- we learn that the argument can be potentially null here
      return true;
    }

    return false;
  }
}

Flow:

While this code barely resembles the original code, it should demonstrate more easily how the issue is raised.


Why is this issue raised?

Without explicit nullability annotations, the analyzer can not know whether null is allowed as an argument to a function or not.
Hence, an opinionated approach is taken: It derives nullability assumptions just based on the explicit != null comparison. The idea is that a programmer would not have written x != null for a symbol x, if x is never null.

How to resolve it for your case

In your specific case, you indeed never expect the argument of hasCause to be null. The != null comparison on the first iteration is an accidental part of the loop construction.

Hence, you can resolve the issue by
either (1) rewriting the loop to a do ... while construction that does not always compare the parameter against null on the first iteration,
or (2) explicitly letting the analyzer know that you never expect a null argument.
I would recommend (2) since it is more practical.

You can for example achieve this with nullability annotations. For example, JSpecify is supported:

import org.jspecify.annotations.NonNull;

public class S2259CatchCauseTraversal {
    static void caller() {
        // ...
    }

    static void handle(RuntimeException exception) {
        hasCause(exception, IllegalStateException.class);

        // ...
    }

    static boolean hasCause(
            @NonNull Throwable throwable, // <-- @NonNull was added here.
            Class<? extends Throwable> type
    ) {
        for (
                Throwable current = throwable;
                current != null;
                current = current.getCause()
        ) {
            // ...
        }

        return false;
    }
}

(alternatively, package-level @NullMarked annotations might be more convenient.)


Please let me know if I was able to address your case adequately, or whether you have further questions.

Best regards,

Anton