https://bz.apache.org/bugzilla/show_bug.cgi?id=70297

            Bug ID: 70297
           Summary: EL function call evaluates the argument that alone
                    fills the varargs parameter twice
           Product: Tomcat 11
           Version: 11.0.26
          Hardware: PC
                OS: Linux
            Status: NEW
          Severity: normal
          Priority: P2
         Component: EL
          Assignee: [email protected]
          Reporter: [email protected]
  Target Milestone: -------

When an EL function whose Java method is varargs is called with exactly one
argument for the varargs parameter,
AstFunction.getValue evaluates that argument expression twice: once for the
isArray(...) check and once more for
the value (java/org/apache/el/parser/AstFunction.java, the inputParameterCount
== methodParameterCount &&
isArray(parameters.jjtGetChild(i).getValue(ctx)) branch, and again in the else
branch when the value is not an
array). The check was added by the fix for bug 66419 (commit 8c6f2972be).

Consequences:
- An argument with side effects runs twice, and the function receives the
result of the second evaluation, so the
  result can differ from a single evaluation (reproducer below: 2 instead of
1).
- An expensive argument (a function that queries a database, for example) costs
double on every evaluation.
- With two or more varargs values each one is evaluated once, so the cost
depends on the argument count.

The caller cannot avoid it: a function mapper maps one Method per name, and the
single-array case
(fn:format('%s-%s', 'one,two'.split(','))) has to keep working.

Reproduced with 9.0.123 and 11.0.26. The code is unchanged on the heads of
main, 11.0.x, 10.1.x and 9.0.x
(2026-10-29, after the 2026-10-18 varargs changes f3bdd779b3 and 1d672d78c8).
The same problem was fixed for method
references on 2026-10-18 (246b894299, "Evaluate each expression once for EL
method references"), but not for functions.

Reproducer

import java.util.concurrent.atomic.AtomicInteger;

import jakarta.el.ELContext;
import jakarta.el.ExpressionFactory;
import jakarta.el.StandardELContext;

public class VarargsDoubleEvaluation {
    private static final AtomicInteger CALLS = new AtomicInteger();

    public static int tick() {
        return CALLS.incrementAndGet();
    }

    public static void main(String[] args) throws Exception {
        ExpressionFactory factory = ExpressionFactory.newInstance();
        ELContext context = new StandardELContext(factory);
        context.getFunctionMapper().mapFunction("fn", "format",
                String.class.getMethod("format", String.class,
Object[].class));
        context.getFunctionMapper().mapFunction("fn", "tick",
VarargsDoubleEvaluation.class.getMethod("tick"));

        Object value = factory.createValueExpression(context,
"${fn:format('%s', fn:tick())}", String.class)
                .getValue(context);
        System.out.println(value + ", tick() calls: " + CALLS.get());
    }
}

Expected 1, tick() calls: 1. Actual: 2, tick() calls: 2. With two values
(${fn:format('%s %s', fn:tick(), fn:tick())}) it is 1 2 and 2 calls, as
expected. (On 9.0.x, use javax.el.)

Proposed fix

Evaluate the argument once and branch on the value:

--- a/java/org/apache/el/parser/AstFunction.java
+++ b/java/org/apache/el/parser/AstFunction.java
@@ -170,9 +170,16 @@
                             // Java semantics: an empty array is passed when
no
                             // varargs elements are supplied
                             params[i] = new Object[0];
-                        } else if (inputParameterCount == methodParameterCount
&&
-                               
isArray(parameters.jjtGetChild(i).getValue(ctx))) {
-                            params[i] =
parameters.jjtGetChild(i).getValue(ctx);
+                        } else if (inputParameterCount ==
methodParameterCount) {
+                            // Evaluate the single argument once: it is either
+                            // the varargs array itself or its only element
+                            Object value =
parameters.jjtGetChild(i).getValue(ctx);
+                            if (isArray(value)) {
+                                params[i] = value;
+                            } else {
+                                params[i] = new Object[] {
ELSupport.coerceToType(ctx, value,
+                                        paramTypes[i].getComponentType()) };
+                            }
                         } else {
                             Object[] varargs = new Object[inputParameterCount
- methodParameterCount + 1];
                             Class<?> target =
paramTypes[i].getComponentType();

The patch applies to main and 11.0.x as is, and to 10.1.x and 9.0.x with an
offset of 5-6 lines. With the patch on the
11.0.x head AstFunction (run on 11.0.26), the reproducer gives 1 and 1 call.
The two-value case and both array
cases (fn:format('%s-%s', 'one,two'.split(',')), a single-parameter varargs
function with 'a,b'.split(',')) give
the same results as before.

A test for test/org/apache/el/parser/TestAstFunction.java, next to
testVarargMethod (needs
import java.util.concurrent.atomic.AtomicInteger;):

    private static final AtomicInteger NEXT = new AtomicInteger();

    public static int next() {
        return NEXT.incrementAndGet();
    }

    @Test
    public void testVarargMethodSingleArgumentEvaluatedOnce() throws
NoSuchMethodException {
        // The argument that alone fills the varargs parameter must be
evaluated
        // once, like any other argument
        ExpressionFactory factory = ExpressionFactory.newInstance();
        ELContext context = new StandardELContext(factory);
        context.getFunctionMapper().mapFunction("fn", "format",
                String.class.getMethod("format", String.class,
Object[].class));
        context.getFunctionMapper().mapFunction("fn", "next",
TestAstFunction.class.getMethod("next"));
        NEXT.set(0);

        Object result = factory.createValueExpression(context,
"${fn:format('%s', fn:next())}", String.class)
                .getValue(context);
        Assert.assertEquals("1", result);
        Assert.assertEquals(1, NEXT.get());
    }

-- 
You are receiving this mail because:
You are the assignee for the bug.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to