The type was answering a question nobody asked

uPortal pull request 2945 changes a callback's type from Function to Consumer. A suppression stops being needed, two anonymous classes collapse to one line each, and the comment above the suppression names a different check from the one the code was working around.

uPortal-Project/uPortal PR 2945 at a20fb52, explained 2026-09-01

Background

New to tee streams, or to ErrorProne? Expand for the deeper background.

uPortal caches the output of a portlet so it can serve the same fragment again without re-rendering it. To do that it needs the bytes twice: once for the response going to the browser, and once for the cache.

A tee stream is the plumbing fixture for that. Named after the T-shaped pipe joint, it wraps one output and duplicates everything written to it into a second, the branch. Write once, land twice.

Caching has a size limit, because a large fragment is not worth holding. So this codebase has a tee that stops when it has seen enough: LimitingTeeOutputStream counts bytes, and once past the maximum it points the branch at a stream that discards everything. The response keeps flowing; the cache copy stops growing.

At that moment something has to throw away the partial cache copy already collected, and only the caller knows how. So the stream takes a callback and invokes it when the limit is hit. The caller here is CachingPortletOutputHandler, the class that owns both the response stream and the cache buffer, and it is the only production code that builds either tee.

One more thing to know. This project compiles with ErrorProne, a Google tool that runs during compilation and reports bug patterns the Java compiler ignores. Each check has a severity: some are warnings, some fail the build. Two of them appear throughout this page. CheckReturnValue reports a call whose result is thrown away. UnusedVariable reports a local that is written and never read. Both are ErrorProne's, not the compiler's.

That is the whole setting, and it is worth seeing before the types. One source of bytes, two destinations, a counter, and a threshold at which the second destination is swapped for one that discards.

Under the limit

portlet writes
bytes
the tee, counting
to the browser
response
to the branch
cache buffer

Once the count passes the maximum

portlet writes
bytes
the tee, counting
to the browser
response, unaffected
to the branch
a discarding stream

At the moment the branch is swapped, the callback fires, and the partial copy already sitting in the cache buffer is thrown away. The response keeps flowing either way. Everything this change touches is in that one callback.

The callback used to be typed as a Guava Function, whose wildcard return says the value is not interesting.

uPortal-rendering/.../cache/LimitingTeeOutputStream.java, before

    private final Function<LimitingTeeOutputStream, ?> limitReachedCallback;

A Function exists to return something. So calling one and dropping the result is the pattern ErrorProne's CheckReturnValue reports. For that check to reach a Guava method, Guava has to opt its own package in, which it does through a package-level annotation. That annotation lives in the Guava jar, which is not part of this checkout, so the page takes it from the library rather than from anything checkable here. The invocation site looked like this.

uPortal-rendering/.../cache/LimitingTeeOutputStream.java, before

            if (this.limitReachedCallback != null) {
                // Named 'unused' to satisfy ErrorProne's CheckReturnValue; the callback's
                // signature is Function<T,?> but its return value is intentionally discarded.
                @SuppressWarnings("unused")
                Object unused = this.limitReachedCallback.apply(this);
            }

Two comment lines, an annotation, and an assignment, to call one callback. The pull request body says that block landed in the immediately preceding pull request. The checkout is a single commit, so it cannot confirm that.

Key concept A functional interface is a contract about shape, and the shape says what the code intends. Function<T, R> means "hand me a T and I will give you back an R". Consumer<T> means "hand me a T and I will go do something". The code here always wanted the second, and had been written with the first.

Intuition

Choosing the type that matches the intent removes the problem. Nothing suppresses CheckReturnValue afterwards, because there is no return value to discard: Consumer.accept is declared void.

Function<T, ?> Consumer<T>
Where it comes from com.google.common.base java.util.function
Method and return apply, returns a value accept, returns void
Discarding the result a bug pattern the build checks for not a concept that applies
Lambda body can be a void call no, it must produce a value yes
Lines at each call site 8 1

The last two rows are connected, and they are the part that is easy to miss. The type change did not only silence a check. It changed what a caller is allowed to write.

Why the call sites collapsed

Both callbacks do one thing: clear the half-collected cache. The methods that do it return void.

In Java, a lambda whose body is a single void method call is a statement, not an expression with a value. That satisfies a Consumer and cannot satisfy a Function, which needs something to return. So under the old type, a caller had no way to write the short form, and had to spell out an anonymous class ending in return null.

uPortal-rendering/.../cache/CachingPortletOutputHandler.java:104, before. The stream call site at :130 is the same shape.

                            new Function<LimitingTeeWriter, Object>() {
                                @Override
                                public Object apply(LimitingTeeWriter input) {
                                    // Limit hit, clear the cache
                                    clearCachedWriter();
                                    return null;
                                }
                            });

uPortal-rendering/.../cache/CachingPortletOutputHandler.java:104, after

                            input -> clearCachedWriter());

The old type required a value back, so the anonymous class had to end in return null.

Edge case The comment in the old code says the local was "named 'unused' to satisfy ErrorProne's CheckReturnValue". Two mechanisms were at work. Storing the result is what satisfied the return-value check; the annotation silenced the unused local that storing it created. The walkthrough works through which one the build needed.

Code walkthrough

The flow, end to end. A portlet writes bytes. The tee counts them and copies them to a cache buffer. Past the limit, it redirects the branch to a discarding stream and invokes the callback, which throws away the partial copy. The change is entirely in how that callback is typed and invoked.

$ grep -c '^diff --git' diff.txt
6

Six files: two production classes, one caller, and three test files.

1. The two classes, in step

uPortal-rendering/.../cache/LimitingTeeOutputStream.java:32

    private final Consumer<LimitingTeeOutputStream> limitReachedCallback;

LimitingTeeWriter.java:32 carries the same declaration at the same line number, with Writer in place of OutputStream. The two classes are the same design twice, one for bytes and one for characters, differing in the names of their counters and in which discarding sentinel they use. Every change in this diff lands on both.

2. The invocation

uPortal-rendering/.../cache/LimitingTeeOutputStream.java:83

            if (this.limitReachedCallback != null) {
-                // Named 'unused' to satisfy ErrorProne's CheckReturnValue; the callback's
-                // signature is Function<T,?> but its return value is intentionally discarded.
-                @SuppressWarnings("unused")
-                Object unused = this.limitReachedCallback.apply(this);
+                this.limitReachedCallback.accept(this);
            }

The null guard stays, because the three-argument constructors of both classes pass null for the callback.

3. The two mechanisms, separated

The old block did two different things, and the comment described them as one.

The assignment is what satisfied CheckReturnValue. That check fires when a call's result is discarded, meaning the call stands alone as a statement. Assigning the result to a variable means it is no longer discarded, so the check has nothing to report. Note that no @SuppressWarnings("CheckReturnValue") appears anywhere; the assignment did the work.

The annotation then dealt with the problem the assignment created. A local variable that is written and never read draws ErrorProne's UnusedVariable, and @SuppressWarnings("unused") is what silences that.

Which of the two mattered for the build depends on severity, and the project configures none of it.

build.gradle:57

    errorprone 'com.google.errorprone:error_prone_core:2.3.4'

There is no errorprone { } block anywhere in the build, no per-check enable, disable, or severity override, and nothing setting -Werror, the flag that would turn warnings into build failures. So every check runs at whatever ErrorProne 2.3.4 ships as its default severity.

Those defaults are the one input this page cannot get from the checkout. ErrorProne is a compile-time dependency, resolved from a repository, and no copy of it is here to read. The values below come from ErrorProne's own documentation for those two checks, not from anything in uPortal, so treat the conclusion that follows as resting on them.

Pre-fix element What it addressed Default severity, per ErrorProne's docs Needed to compile
Object unused = ... CheckReturnValue error yes
@SuppressWarnings("unused") UnusedVariable warning no

On those severities, the assignment was required to compile and the annotation was tidying. Either way, the annotation addressed the unused local, and the comment above it named the return-value check.

4. The call site that could not change type

One file in this diff is in a different package and changes for a different reason.

uPortal-webapp/.../events/aggr/PortalRawEventsAggregatorImplTest.java:178

                                Boolean ignored =
                                        ((Function<PortalEvent, Boolean>)
                                                        invocation.getArguments()[3])
                                                .apply(

Here the Function is the parameter type of the method under test, so the test cannot choose a different one. The same discarded-result problem applies, and the same assignment fixes it, with a local named ignored and no annotation.

When you own the interface, change the type. When you do not, the assignment is the answer. This diff contains one of each.

5. The argument no caller reads

Both classes pass themselves to the callback, and every callback in the checkout ignores it. The two production lambdas are input -> clearCachedWriter() and input -> clearCachedStream(); the two test callbacks reset a buffer they captured instead. No caller reads the argument. CachingPortletOutputHandler is the only production code that constructs either class; the two test files construct them eight times between them.

The parameter is still declared and still supplied. Nothing in the record says why it stayed. My reading is that it leaves room for a callback that wants the stream, which a parameterless Runnable would foreclose.

6. How far this reaches

The Guava import leaves five files, and java.util.function.Consumer arrives in two. The three other files need no new import, because a lambda needs none.

Guava itself stays. It is not declared in this module directly; it arrives transitively as an api dependency of uPortal-utils-core. Counting what remains in the module:

$ grep -rn 'import com.google.common' --include='*.java' uPortal-rendering/src | wc -l
33

Six of those 33 are still com.google.common.base.Function, found with:

$ grep -rn 'import com.google.common.base.Function' --include='*.java' uPortal-rendering/src | wc -l
6

So the same import exists at six other places in this module. What those six do with their Function is a separate question from this change.

One constraint worth knowing, since it explains why the fix uses what it uses. The project's AGENTS.md bans Java 9 and later language features and APIs. Consumer and lambdas are both Java 8, so this change stays inside that rule.

7. What the checkout cannot confirm

The pull request body tells a story about how this change came to be a separate pull request: a contributor proposed the Consumer version on another branch, the author force-pushed a cherry-pick onto the earlier pull request, and that earlier pull request had already merged with the @SuppressWarnings version. So this lands as a separate follow-up, which the body says credits the contributor as co-author.

Most of that is not checkable here. The checkout is a single commit with no ancestor history, so the referenced pull requests, the branch, and the force-push cannot be inspected. The file list and the per-file description in the body do match the diff exactly.

The co-author credit is checkable, and it does not match. The single commit carries two Co-Authored-By trailers, and neither names the contributor the body says is credited. What that means is not something the checkout can tell you; a credit can also be recorded in a merge commit this clone does not have.

A value this checkout does not share The body says the contributor's branch carries resourceServerVersion=1.5.2 and therefore triggers the underlying Guava CheckReturnValue check. This checkout's gradle.properties sets that property to 1.3.1. Both statements can be true at once, since they describe different branches, and the page notes it because it changes what a reader can conclude: on this checkout's own classpath, it is not established that the check fired at all.
The transferable part When a static-analysis tool objects and the fix is a variable you immediately annotate away, read the objection again. The tool is usually describing the type, not the line. A callback typed to return a value it never returns produces the same complaint at every call site. Changing the type removes it at all of them, and often shortens the callers, because what they were working around was the return value they never wanted.

Quiz

Five questions about why the change is shaped the way it is. Click an option to see the answer.

1. Under the old type, why could a caller not write input -> clearCachedWriter() and skip the anonymous class?

This is why the type change shortened the callers rather than only quieting a check: it changed what a caller is allowed to write. Any functional interface can be targeted by a lambda, Guava's included, as long as the body fits its method. The wildcard is satisfiable too; a body returning null is what the old code wrote.

2. The old code carried @SuppressWarnings("unused") above Object unused = callback.apply(this). Which check was that annotation silencing?

The suppression string for the return-value check would have been "CheckReturnValue", and it appears nowhere. What satisfied that check was storing the result instead of discarding it, and the leftover variable is what needed "unused". The comment credited the annotation with the assignment's job. Plain javac emits no unused-local warning here, since the build sets no -Xlint.

3. Of the two pieces of the old workaround, which one was actually required for the build to pass?

The build declares ErrorProne and configures nothing else, so every check runs at its default severity. That makes the annotation tidying and the assignment the part that kept compilation alive. An unused local is legal Java; only a tool complains about it.

4. One test file keeps the pattern this change deletes, assigning to a local named ignored. Why was it not converted to a Consumer as well?

That contrast is the useful part of this diff: when you own the interface you change the type, and when you do not, the assignment is the answer. The module boundary is real but not the obstacle, and ErrorProne compiles test sources the same way.

5. The callback receives the stream or writer as its argument, and no callback in the checkout reads it. What does declaring the parameter still buy?

Runnable is the parameterless equivalent and would fit every current caller, so the parameter is a choice rather than a requirement. Invoking a callback needs no argument, and the JDK has several void functional interfaces, including Runnable and BiConsumer.