From 9802a1a40b22d7c4c2caa2fddd75d8e8d47243d7 Mon Sep 17 00:00:00 2001 From: Paul King Date: Wed, 9 Sep 2026 17:06:13 +1000 Subject: [PATCH] GROOVY-12387: Indy: exception-handler combinator bypassed on ART --- .../groovy/vmplugin/v8/IndyCatchCompat.java | 117 +++++++++++++++ .../codehaus/groovy/vmplugin/v8/Selector.java | 19 ++- .../vmplugin/v8/IndyCatchCompatTest.groovy | 136 ++++++++++++++++++ 3 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCatchCompat.java create mode 100644 src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyCatchCompatTest.groovy diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCatchCompat.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCatchCompat.java new file mode 100644 index 00000000000..71158c579d9 --- /dev/null +++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCatchCompat.java @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.codehaus.groovy.vmplugin.v8; + +import groovy.lang.GroovyRuntimeException; +import groovy.lang.MissingMethodException; +import org.codehaus.groovy.GroovyBugError; +import org.codehaus.groovy.runtime.ScriptBytecodeAdapter; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; + +/** + * Exception handling around a call-site target expressed in plain Java rather + * than with {@link MethodHandles#catchException} (GROOVY-12387). + *

+ * Android's ART implements {@code catchException} with an exact class test, + * {@code thrown.getClass() == exType}, where the JDK applies + * {@code exType.isInstance(thrown)}, so an exception of a subclass of the + * declared type bypasses the handler (libcore {@code Transformers.CatchException}). + * The runtime throws subclasses at both of the Selector's handler sites: + * {@code MissingMethodExceptionNoStack} where {@code MissingMethodException} is + * declared, and the whole {@code GroovyRuntimeException} hierarchy at the + * unwrapper. On ART the GroovyObject fallback behind a failed metaclass call + * therefore never ran and runtime exceptions escaped unwrapped. The + * {@link Selector} uses these wrappers instead of the combinator when running + * on Android; on a JVM the combinator stays and the runtime never calls + * these wrappers. + *

+ * The wrappers box and collect arguments on every call, which is acceptable + * on ART, where method handle chains are interpreted anyway. + */ +final class IndyCatchCompat { + + private static final MethodType INVOKE_TYPE = + MethodType.methodType(Object.class, Object.class, String.class, Object[].class); + private static final MethodType SPREAD_TYPE = + MethodType.methodType(Object.class, Object[].class); + + private static final MethodHandle INVOKE_WITH_FALLBACK; + private static final MethodHandle INVOKE_UNWRAPPING; + + static { + try { + MethodHandles.Lookup lookup = MethodHandles.lookup(); + INVOKE_WITH_FALLBACK = lookup.findStatic(IndyCatchCompat.class, "invokeWithFallback", + MethodType.methodType(Object.class, MethodHandle.class, Object.class, String.class, Object[].class)); + INVOKE_UNWRAPPING = lookup.findStatic(IndyCatchCompat.class, "invokeUnwrapping", + MethodType.methodType(Object.class, MethodHandle.class, Object[].class)); + } catch (ReflectiveOperationException e) { + throw new GroovyBugError(e); + } + } + + private IndyCatchCompat() { + } + + /** + * Wraps a metaclass invocation handle of type {@code (Object receiver, + * String name, Object[] args)Object} so that a {@link MissingMethodException} + * is routed to {@link IndyGuardsFiltersAndSignatures#invokeGroovyObjectInvoker}. + * + * @param target the metaclass invocation handle + * @return a handle of the same type with the fallback attached + */ + static MethodHandle withGroovyObjectFallback(final MethodHandle target) { + return INVOKE_WITH_FALLBACK.bindTo(target.asType(INVOKE_TYPE)); + } + + /** + * Wraps a handle of any type so that a {@link GroovyRuntimeException} thrown + * by it is replaced with {@link ScriptBytecodeAdapter#unwrap}'s result, as + * {@link Selector.MethodSelector#addExceptionHandler} does with the combinator. + * + * @param target the call-site target + * @return a handle of the same type that unwraps runtime exceptions + */ + static MethodHandle unwrapping(final MethodHandle target) { + MethodType type = target.type(); + int arity = type.parameterCount(); + MethodHandle spread = target.asSpreader(Object[].class, arity).asType(SPREAD_TYPE); + return INVOKE_UNWRAPPING.bindTo(spread).asCollector(Object[].class, arity).asType(type); + } + + private static Object invokeWithFallback(final MethodHandle target, final Object receiver, final String name, final Object[] args) throws Throwable { + try { + return target.invokeExact(receiver, name, args); + } catch (MissingMethodException e) { + return IndyGuardsFiltersAndSignatures.invokeGroovyObjectInvoker(e, receiver, name, args); + } + } + + private static Object invokeUnwrapping(final MethodHandle target, final Object[] args) throws Throwable { + try { + return target.invokeExact(args); + } catch (GroovyRuntimeException e) { + throw ScriptBytecodeAdapter.unwrap(e); + } + } +} diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java index 80cc62761ac..baf2ea608c1 100644 --- a/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java +++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java @@ -37,6 +37,7 @@ import org.codehaus.groovy.GroovyBugError; import org.codehaus.groovy.reflection.CachedField; import org.codehaus.groovy.reflection.CachedMethod; +import org.codehaus.groovy.reflection.android.AndroidSupport; import org.codehaus.groovy.reflection.ClassInfo; import org.codehaus.groovy.reflection.GeneratedMetaMethod; import org.codehaus.groovy.reflection.stdclasses.CachedSAMClass; @@ -1168,7 +1169,9 @@ public void setMetaClassCallHandleIfNeeded(boolean standardMetaClass) { // if the metaclass call fails we may still want to fall back to call // GroovyObject#invokeMethod if the receiver is a GroovyObject if (LOG_ENABLED) LOG.info("add MissingMethod handler for GroovyObject#invokeMethod fallback path"); - handle = MethodHandles.catchException(handle, MissingMethodException.class, GROOVY_OBJECT_INVOKER); + handle = AndroidSupport.isRunningAndroid() + ? IndyCatchCompat.withGroovyObjectFallback(handle) // GROOVY-12387 + : MethodHandles.catchException(handle, MissingMethodException.class, GROOVY_OBJECT_INVOKER); } } handle = MethodHandles.insertArguments(handle, 1, name); @@ -1319,12 +1322,16 @@ public void addExceptionHandler() { //TODO: if we would know exactly which paths require the exceptions // and which paths not, we can sometimes save this guard if (handle == null || !catchException) return; - Class returnType = handle.type().returnType(); - if (returnType != Object.class) { - MethodType mtype = MethodType.methodType(returnType, GroovyRuntimeException.class); - handle = MethodHandles.catchException(handle, GroovyRuntimeException.class, UNWRAP_EXCEPTION.asType(mtype)); + if (AndroidSupport.isRunningAndroid()) { + handle = IndyCatchCompat.unwrapping(handle); // GROOVY-12387 } else { - handle = MethodHandles.catchException(handle, GroovyRuntimeException.class, UNWRAP_EXCEPTION); + Class returnType = handle.type().returnType(); + if (returnType != Object.class) { + MethodType mtype = MethodType.methodType(returnType, GroovyRuntimeException.class); + handle = MethodHandles.catchException(handle, GroovyRuntimeException.class, UNWRAP_EXCEPTION.asType(mtype)); + } else { + handle = MethodHandles.catchException(handle, GroovyRuntimeException.class, UNWRAP_EXCEPTION); + } } if (LOG_ENABLED) LOG.info("added GroovyRuntimeException unwrapper"); } diff --git a/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyCatchCompatTest.groovy b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyCatchCompatTest.groovy new file mode 100644 index 00000000000..a53cd2e6cac --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyCatchCompatTest.groovy @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.codehaus.groovy.vmplugin.v8 + +import groovy.lang.GroovyObjectSupport +import groovy.lang.MissingMethodException +import groovy.lang.MissingPropertyException +import org.codehaus.groovy.runtime.InvokerInvocationException +import org.codehaus.groovy.runtime.metaclass.MissingMethodExceptionNoStack +import org.codehaus.groovy.runtime.metaclass.MissingPropertyExceptionNoStack +import org.junit.jupiter.api.Test + +import java.lang.invoke.MethodHandle +import java.lang.invoke.MethodHandles +import java.lang.invoke.MethodType + +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertFalse +import static org.junit.jupiter.api.Assertions.assertThrows +import static org.junit.jupiter.api.Assertions.assertTrue + +/** + * GROOVY-12387: the plain-Java replacements for {@code MethodHandles.catchException} + * that the Selector uses on Android must behave exactly like the JDK combinator, + * including for subclasses of the declared exception type, which ART's exact-class + * check lets through; the runtime's no-stack exceptions are such subclasses. + */ +final class IndyCatchCompatTest { + + private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup() + + // ---- targets standing in for a metaclass invocation: (Object, String, Object[])Object + + static Object succeed(Object receiver, String name, Object[] args) { name + '(' + args.join(',') + ')' } + static Object missingNoStack(Object receiver, String name, Object[] args) { + throw new MissingMethodExceptionNoStack(name, receiver.getClass(), args) + } + static Object explode(Object receiver, String name, Object[] args) { throw new IllegalStateException('boom') } + + static class Fallback extends GroovyObjectSupport { + @Override Object invokeMethod(String name, Object args) { 'fallback:' + name } + } + + // fixed arity: Groovy flags a trailing array parameter as varargs, and invokeWithArguments + // would otherwise collect the Object[] argument into a fresh array + private static MethodHandle target(String method) { + LOOKUP.findStatic(IndyCatchCompatTest, method, MethodType.methodType(Object, Object, String, Object[])).asFixedArity() + } + + @Test + void groovyObjectFallbackRunsForANoStackMissingMethod() { + def handle = IndyCatchCompat.withGroovyObjectFallback(target('missingNoStack')) + assertEquals('fallback:foo', handle.invokeWithArguments(new Fallback(), 'foo', [1, 2] as Object[])) + } + + @Test + void groovyObjectFallbackIsTransparentOtherwise() { + def ok = IndyCatchCompat.withGroovyObjectFallback(target('succeed')) + assertEquals('foo(1,2)', ok.invokeWithArguments(new Fallback(), 'foo', [1, 2] as Object[])) + def other = IndyCatchCompat.withGroovyObjectFallback(target('explode')) + assertThrows(IllegalStateException) { other.invokeWithArguments(new Fallback(), 'foo', new Object[0]) } + } + + @Test + void groovyObjectFallbackRethrowsWhenTheReceiverDoesNotMatch() { + // the invoker only delegates when the exception names the receiver's own class + def handle = IndyCatchCompat.withGroovyObjectFallback(target('missingNoStack')) + def receiver = new Fallback() + def wrong = target('missingForOther') + def e = assertThrows(MissingMethodException) { + IndyCatchCompat.withGroovyObjectFallback(wrong).invokeWithArguments(receiver, 'foo', new Object[0]) + } + assertEquals('foo', e.method) + assertEquals('fallback:foo', handle.invokeWithArguments(receiver, 'foo', new Object[0])) + } + + static Object missingForOther(Object receiver, String name, Object[] args) { + throw new MissingMethodExceptionNoStack(name, String, args) + } + + // ---- unwrapping around arbitrary call-site shapes + + static boolean check(int n, String s) { if (n < 0) throw new MissingPropertyExceptionNoStack(s, Integer); n > s.length() } + static void act(String s) { throw new InvokerInvocationException(new IllegalArgumentException(s)) } + static Object noStack(Object o) { throw new MissingMethodExceptionNoStack('bar', o.getClass(), new Object[0]) } + + @Test + void unwrappingKeepsTheCallSiteTypeAndPassesArguments() { + def target = LOOKUP.findStatic(IndyCatchCompatTest, 'check', MethodType.methodType(boolean, int, String)) + def handle = IndyCatchCompat.unwrapping(target) + assertEquals(target.type(), handle.type()) + assertTrue(handle.invokeWithArguments(5, 'abc')) + assertFalse(handle.invokeWithArguments(1, 'abc')) + } + + @Test + void unwrappingTurnsNoStackExceptionsIntoTheirStackfulKind() { + def property = IndyCatchCompat.unwrapping(LOOKUP.findStatic(IndyCatchCompatTest, 'check', MethodType.methodType(boolean, int, String))) + def mpe = assertThrows(MissingPropertyException) { property.invokeWithArguments(-1, 'p') } + assertFalse(mpe instanceof MissingPropertyExceptionNoStack) + assertEquals('p', mpe.property) + assertTrue(mpe.stackTrace.length > 0) + + def method = IndyCatchCompat.unwrapping(LOOKUP.findStatic(IndyCatchCompatTest, 'noStack', MethodType.methodType(Object, Object))) + def mme = assertThrows(MissingMethodException) { method.invokeWithArguments('x') } + assertFalse(mme instanceof MissingMethodExceptionNoStack) + assertEquals('bar', mme.method) + } + + @Test + void unwrappingUnwrapsInvokerInvocationExceptionsAndSupportsVoid() { + def voidTarget = LOOKUP.findStatic(IndyCatchCompatTest, 'act', MethodType.methodType(void, String)) + def handle = IndyCatchCompat.unwrapping(voidTarget) + assertEquals(voidTarget.type(), handle.type()) + def e = assertThrows(IllegalArgumentException) { handle.invokeWithArguments('why') } + assertEquals('why', e.message) + def passThrough = IndyCatchCompat.unwrapping(target('succeed')) + assertEquals('n(7)', passThrough.invokeWithArguments(null, 'n', [7] as Object[])) + } +}