diff --git a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapter.java b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapter.java index 6fce09dfdb2..621add5bbc4 100644 --- a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapter.java +++ b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapter.java @@ -1,57 +1,79 @@ package datadog.trace.instrumentation.servlet3; -import static java.util.Collections.emptyEnumeration; -import static java.util.Collections.enumeration; - import datadog.trace.bootstrap.instrumentation.api.AgentPropagation; +import datadog.trace.util.ClassLatch; import java.util.Collection; import java.util.Enumeration; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public abstract class HttpServletExtractAdapter implements AgentPropagation.ContextVisitor { - abstract Enumeration getHeaderNames(T t); - - abstract String getHeader(T t, String name); - - @Override - public void forEachKey(T carrier, AgentPropagation.KeyClassifier classifier) { - Enumeration headerNames = getHeaderNames(carrier); - while (headerNames.hasMoreElements()) { - String header = headerNames.nextElement(); - if (!classifier.accept(header, getHeader(carrier, header))) { - break; - } - } - } public static final class Request extends HttpServletExtractAdapter { public static final Request GETTER = new Request(); @Override - Enumeration getHeaderNames(HttpServletRequest request) { - final Enumeration ret = request.getHeaderNames(); - return ret != null ? ret : emptyEnumeration(); - } - - @Override - String getHeader(HttpServletRequest request, String name) { - return request.getHeader(name); + public void forEachKey(HttpServletRequest carrier, AgentPropagation.KeyClassifier classifier) { + final Enumeration headerNames = carrier.getHeaderNames(); + if (headerNames == null) { + return; + } + while (headerNames.hasMoreElements()) { + String header = headerNames.nextElement(); + if (!classifier.accept(header, carrier.getHeader(header))) { + break; + } + } } } + /** + * Reads response headers through the Servlet 3.0 accessors. A response class compiled against + * Servlet 2.5 that implements {@link HttpServletResponse} itself does not implement them, so + * calling them throws {@link AbstractMethodError}. Such classes are latched and visit no headers. + * Subclasses of {@code HttpServletResponseWrapper} are not affected: they inherit the container's + * delegating implementations. + */ public static final class Response extends HttpServletExtractAdapter { public static final Response GETTER = new Response(); + static final HeaderAccessLatch HEADER_LATCH = new HeaderAccessLatch(); + @Override - Enumeration getHeaderNames(HttpServletResponse response) { - final Collection headerNames = response.getHeaderNames(); - return headerNames != null ? enumeration(headerNames) : emptyEnumeration(); + public void forEachKey(HttpServletResponse carrier, AgentPropagation.KeyClassifier classifier) { + final Collection headerNames = HEADER_LATCH.tryApply(carrier); + if (headerNames == null) { + return; + } + for (String header : headerNames) { + // only the accessor call is guarded: an AbstractMethodError from the classifier callback + // is not evidence that this response class lacks the accessors + final String value; + try { + value = carrier.getHeader(header); + } catch (AbstractMethodError e) { + // only partly implemented: latch and stop instead of retrying, which would re-emit keys + HEADER_LATCH.latchIfLacksGetHeader(carrier, e); + return; + } + if (!classifier.accept(header, value)) { + return; + } + } } - @Override - String getHeader(HttpServletResponse response, String name) { - return response.getHeader(name); + /** Skips response classes that do not implement the Servlet 3.0 header accessors. */ + static final class HeaderAccessLatch + extends ClassLatch, RuntimeException> { + @Override + protected Collection apply(HttpServletResponse response) { + return handleAbstractMethod( + response, "getHeaderNames", HttpServletResponse::getHeaderNames); + } + + void latchIfLacksGetHeader(HttpServletResponse response, AbstractMethodError error) { + latchIfNamed(response, "getHeader", error); + } } } } diff --git a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Instrumentation.java b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Instrumentation.java index 4835d9d2b0b..ac5f52347df 100644 --- a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Instrumentation.java +++ b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Instrumentation.java @@ -49,6 +49,7 @@ public String[] helperClassNames() { packageName + ".HttpServletExtractAdapter", packageName + ".HttpServletExtractAdapter$Request", packageName + ".HttpServletExtractAdapter$Response", + packageName + ".HttpServletExtractAdapter$Response$HeaderAccessLatch", packageName + ".Servlet3Decorator", packageName + ".ServletRequestURIAdapter", packageName + ".FinishAsyncDispatchListener", diff --git a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/test/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapterTest.java b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/test/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapterTest.java new file mode 100644 index 00000000000..39dc592d4f6 --- /dev/null +++ b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/test/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapterTest.java @@ -0,0 +1,235 @@ +package datadog.trace.instrumentation.servlet3; + +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static net.bytebuddy.jar.asm.Opcodes.ACC_PUBLIC; +import static net.bytebuddy.jar.asm.Opcodes.ALOAD; +import static net.bytebuddy.jar.asm.Opcodes.ARETURN; +import static net.bytebuddy.jar.asm.Opcodes.INVOKESPECIAL; +import static net.bytebuddy.jar.asm.Opcodes.INVOKESTATIC; +import static net.bytebuddy.jar.asm.Opcodes.RETURN; +import static net.bytebuddy.jar.asm.Opcodes.V1_8; +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; + +import datadog.trace.bootstrap.instrumentation.api.AgentPropagation; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import javax.servlet.http.HttpServletResponse; +import net.bytebuddy.jar.asm.ClassWriter; +import net.bytebuddy.jar.asm.MethodVisitor; +import org.junit.jupiter.api.Test; + +class HttpServletExtractAdapterTest { + + private static final HttpServletExtractAdapter.Response GETTER = + HttpServletExtractAdapter.Response.GETTER; + private static final HttpServletExtractAdapter.Response.HeaderAccessLatch HEADER_LATCH = + HttpServletExtractAdapter.Response.HEADER_LATCH; + + @Test + void readsHeadersFromSupportedResponse() { + List seen = new ArrayList<>(); + + GETTER.forEachKey(supportedResponse(), collectInto(seen)); + + assertEquals(asList("x-a=1", "x-b=2"), seen); + } + + @Test + void skipsResponseClassWithoutHeaderAccessors() throws Exception { + // sanity check: calling the Servlet 3.0 method on this class really fails + HttpServletResponse old = newOldResponse("OldResponse"); + assertThrows(AbstractMethodError.class, old::getHeaderNames); + List seen = new ArrayList<>(); + + // the first response of the class trips the failure path + GETTER.forEachKey(old, collectInto(seen)); + // later responses of the same class take the remembered path + GETTER.forEachKey(old, collectInto(seen)); + + assertEquals(emptyList(), seen); + } + + @Test + void healthyResponsesStillWorkAfterAFailure() throws Exception { + // tests can run in any order, so trip the failure path here to cover healthy responses after it + List failed = new ArrayList<>(); + GETTER.forEachKey(newOldResponse("OldResponseTwo"), collectInto(failed)); + assertEquals(emptyList(), failed); + List seen = new ArrayList<>(); + + GETTER.forEachKey(supportedResponse(), collectInto(seen)); + + assertEquals(asList("x-a=1", "x-b=2"), seen); + } + + @Test + void callbackFailureDoesNotMarkResponseClassUnsupported() { + // an AbstractMethodError raised by the callback says nothing about the response class + assertThrows( + AbstractMethodError.class, + () -> + GETTER.forEachKey( + supportedResponse(), + (key, value) -> { + throw new AbstractMethodError(); + })); + List seen = new ArrayList<>(); + + GETTER.forEachKey(supportedResponse(), collectInto(seen)); + + assertEquals(asList("x-a=1", "x-b=2"), seen); + } + + @Test + void latchesResponseClassWithoutHeaderAccessors() throws Exception { + warmAccessorCallSites(); + HttpServletResponse old = newOldResponse("OldResponseLatched"); + assertFalse(HEADER_LATCH.isLatched(old)); + + GETTER.forEachKey(old, collectInto(new ArrayList<>())); + + assertTrue(HEADER_LATCH.isLatched(old)); + assertFalse(HEADER_LATCH.isLatched(supportedResponse())); + } + + @Test + void latchesResponseClassMissingOnlyGetHeader() throws Exception { + warmAccessorCallSites(); + // not producible by javac, but bytecode generators can implement one accessor and not the other + HttpServletResponse partial = newPartialResponse("PartialResponse"); + List seen = new ArrayList<>(); + + GETTER.forEachKey(partial, collectInto(seen)); + GETTER.forEachKey(partial, collectInto(seen)); + + assertEquals(emptyList(), seen); + assertTrue(HEADER_LATCH.isLatched(partial)); + } + + @Test + void oldWrapperSubclassInheritsHeaderAccessors() throws Exception { + // a wrapper compiled against Servlet 2.5 inherits the container's delegating accessors, which + // is why these classes need no unwrapping + HttpServletResponse oldWrapper = newOldWrapper("OldWrapper", supportedResponse()); + List seen = new ArrayList<>(); + + GETTER.forEachKey(oldWrapper, collectInto(seen)); + + assertEquals(asList("x-a=1", "x-b=2"), seen); + assertFalse(HEADER_LATCH.isLatched(oldWrapper)); + } + + /** + * Calls the accessors on a working class first. JDK 8 then throws the AbstractMethodError without + * a message, as it does in production, where the call sites have seen healthy responses. + */ + private static void warmAccessorCallSites() { + GETTER.forEachKey(supportedResponse(), collectInto(new ArrayList<>())); + } + + private static HttpServletResponse supportedResponse() { + return (HttpServletResponse) + Proxy.newProxyInstance( + HttpServletResponse.class.getClassLoader(), + new Class[] {HttpServletResponse.class}, + (proxy, method, args) -> { + switch (method.getName()) { + case "getHeaderNames": + return asList("x-a", "x-b"); + case "getHeader": + return "x-a".equals(args[0]) ? "1" : "2"; + default: + return null; + } + }); + } + + private static AgentPropagation.KeyClassifier collectInto(List seen) { + return (key, value) -> { + seen.add(key + "=" + value); + return true; + }; + } + + /** Defines a class that implements {@link HttpServletResponse} but none of its methods. */ + private static HttpServletResponse newOldResponse(String name) throws Exception { + return (HttpServletResponse) + define(name, "java/lang/Object", "()V", true, false).getConstructor().newInstance(); + } + + /** Like {@link #newOldResponse}, but implements {@code getHeaderNames} (one name) only. */ + private static HttpServletResponse newPartialResponse(String name) throws Exception { + return (HttpServletResponse) + define(name, "java/lang/Object", "()V", true, true).getConstructor().newInstance(); + } + + /** A {@code HttpServletResponseWrapper} subclass that, like 2.5 code, overrides nothing. */ + private static HttpServletResponse newOldWrapper(String name, HttpServletResponse delegate) + throws Exception { + return (HttpServletResponse) + define( + name, + "javax/servlet/http/HttpServletResponseWrapper", + "(Ljavax/servlet/http/HttpServletResponse;)V", + false, + false) + .getConstructor(HttpServletResponse.class) + .newInstance(delegate); + } + + /** The constructor just forwards its (optional) argument to the same-shaped super constructor. */ + private static Class define( + String name, + String superName, + String ctorDesc, + boolean implementsResponse, + boolean withHeaderNames) + throws Exception { + String internalName = "test/" + name; + ClassWriter writer = new ClassWriter(0); + writer.visit( + V1_8, + ACC_PUBLIC, + internalName, + null, + superName, + implementsResponse ? new String[] {"javax/servlet/http/HttpServletResponse"} : null); + MethodVisitor init = writer.visitMethod(ACC_PUBLIC, "", ctorDesc, null, null); + init.visitCode(); + init.visitVarInsn(ALOAD, 0); + if (!"()V".equals(ctorDesc)) { + init.visitVarInsn(ALOAD, 1); + } + init.visitMethodInsn(INVOKESPECIAL, superName, "", ctorDesc, false); + init.visitInsn(RETURN); + init.visitMaxs(2, 2); + init.visitEnd(); + if (withHeaderNames) { + MethodVisitor names = + writer.visitMethod(ACC_PUBLIC, "getHeaderNames", "()Ljava/util/Collection;", null, null); + names.visitCode(); + names.visitLdcInsn("x-a"); + names.visitMethodInsn( + INVOKESTATIC, + "java/util/Collections", + "singletonList", + "(Ljava/lang/Object;)Ljava/util/List;", + false); + names.visitInsn(ARETURN); + names.visitMaxs(1, 1); + names.visitEnd(); + } + writer.visitEnd(); + byte[] bytes = writer.toByteArray(); + return new ClassLoader(HttpServletExtractAdapterTest.class.getClassLoader()) { + Class define() { + return defineClass("test." + name, bytes, 0, bytes.length); + } + }.define(); + } +} diff --git a/internal-api/src/main/java/datadog/trace/util/ClassLatch.java b/internal-api/src/main/java/datadog/trace/util/ClassLatch.java index b93ad101f3e..b9c6e41a3bb 100644 --- a/internal-api/src/main/java/datadog/trace/util/ClassLatch.java +++ b/internal-api/src/main/java/datadog/trace/util/ClassLatch.java @@ -3,6 +3,9 @@ import datadog.trace.api.function.Strategy; import datadog.trace.api.function.StrategyConsumer; import datadog.trace.api.function.ThrowingFunction; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; import javax.annotation.Nullable; import javax.annotation.concurrent.ThreadSafe; @@ -143,10 +146,11 @@ protected final void unlatch(T target) { /** * Latches the target's key if the error's message names that class as the receiver that lacks - * {@code methodName}. Returns whether it latched. An error that does not name the key, for - * example one thrown inside a wrapper's delegate, is left alone — and so is one that names the - * key but for a different method, for example one the guarded method's own implementation calls - * internally. + * {@code methodName}, or, when the JVM gave no message, if that class still has {@code + * methodName} as an abstract method. Returns whether it latched. An error that does not name the + * key, for example one thrown inside a wrapper's delegate, is left alone — and so is one that + * names the key but for a different method, for example one the guarded method's own + * implementation calls internally. */ protected final boolean latchIfNamed(T target, String methodName, AbstractMethodError error) { if (isNamedIn(error, keyOf(target), methodName)) { @@ -159,10 +163,10 @@ protected final boolean latchIfNamed(T target, String methodName, AbstractMethod /** * For a call to a method that some implementations may lack: returns {@link #fallback} if the * call raises {@link AbstractMethodError} or {@link UnsupportedOperationException}, latching the - * key first in the former case if, and only if, the error names it (see {@link #latchIfNamed}). - * An error that does not name the key still yields the fallback; it just is not latched. An - * unsupported operation names no class, so it is never latched, and is caught on every call. - * Anything else, checked exceptions included, propagates unchanged. + * key first in the former case if, and only if, the error is attributed to it (see {@link + * #latchIfNamed}). An error that is not attributed to the key still yields the fallback; it just + * is not latched. An unsupported operation names no class, so it is never latched, and is caught + * on every call. Anything else, checked exceptions included, propagates unchanged. * *
{@code
    * protected Properties apply(Connection c) throws SQLException {
@@ -236,8 +240,9 @@ protected final R handleNoSuchMethod(T target, @Strategy ThrowingFunction{@code
    * protected Properties apply(Connection c) throws SQLException {
@@ -257,6 +262,51 @@ protected final R handleNoSuchOrAbstractMethod(
     }
   }
 
+  /**
+   * Whether a public method named {@code methodName} is still abstract on {@code type}, as an
+   * interface method that a concrete class never implemented is. This attributes the error to the
+   * class just as a message naming it would: a wrapper that delegates the method has a concrete
+   * implementation, so an error raised by its delegate is not attributed to it. Overloads are not
+   * told apart. Each class is scanned once, whatever the answer, so a class that keeps failing
+   * without being latched does not repeat the reflection.
+   */
+  static boolean lacksImplementation(Class type, String methodName) {
+    for (final String abstractMethod : ABSTRACT_METHOD_NAMES.get(type)) {
+      if (abstractMethod.equals(methodName)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private static final String[] NO_NAMES = new String[0];
+
+  /**
+   * Per class, the names of its public methods that are still abstract. Only computed for classes
+   * that failed without a message. The value is a JDK type, like {@link #latched}'s.
+   */
+  private static final ClassValue ABSTRACT_METHOD_NAMES =
+      new ClassValue() {
+        @Override
+        protected String[] computeValue(Class type) {
+          try {
+            int count = 0;
+            final Method[] methods = type.getMethods();
+            final String[] names = new String[methods.length];
+            for (final Method method : methods) {
+              if (Modifier.isAbstract(method.getModifiers())) {
+                names[count++] = method.getName();
+              }
+            }
+            return count == 0 ? NO_NAMES : Arrays.copyOf(names, count);
+          } catch (final SecurityException | LinkageError ignored) {
+            // cannot tell, so never latch: getMethods() resolves every public signature, and one
+            // that references a class missing from the class path raises NoClassDefFoundError
+            return NO_NAMES;
+          }
+        }
+      };
+
   /**
    * Matches the receiver class named by HotSpot's message against a class. Two formats exist:
    *
@@ -271,11 +321,15 @@ protected final R handleNoSuchOrAbstractMethod(
    * method's implementation can call a different, unimplemented method on the very same receiver,
    * raising an error that names the key class but blames a method other than {@code methodName}, so
    * the method name is checked too. An unparseable message never matches.
+   *
+   * 

JDK 8 omits the message once the call site has dispatched to a class that does implement the + * method, which in production is nearly always. Without a message the class itself is checked + * instead: see {@link #lacksImplementation}. */ static boolean isNamedIn(AbstractMethodError e, Class type, String methodName) { final String message = e.getMessage(); if (message == null) { - return false; + return lacksImplementation(type, methodName); } final String name = type.getName(); if (message.startsWith(RECEIVER_PREFIX)) { diff --git a/internal-api/src/test/java/datadog/trace/util/ClassLatchTest.java b/internal-api/src/test/java/datadog/trace/util/ClassLatchTest.java index c41c439d8ca..6dd220f95cc 100644 --- a/internal-api/src/test/java/datadog/trace/util/ClassLatchTest.java +++ b/internal-api/src/test/java/datadog/trace/util/ClassLatchTest.java @@ -6,6 +6,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; import java.sql.SQLException; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; @@ -300,12 +303,126 @@ void attributesTheHotSpotMessageFormats() { assertFalse(ClassLatch.isNamedIn(new AbstractMethodError(name), String.class, "getClientInfo")); assertFalse( ClassLatch.isNamedIn(new AbstractMethodError(name + "."), String.class, "getClientInfo")); + // no message: String has no abstract getClientInfo, so the error is not attributed to it assertFalse(ClassLatch.isNamedIn(new AbstractMethodError(), String.class, "getClientInfo")); assertFalse( ClassLatch.isNamedIn( new AbstractMethodError("something else"), String.class, "getClientInfo")); } + /** Stands in for a class that never implemented an interface method it declares. */ + abstract static class Unimplemented { + public abstract String m(); + + public String implemented() { + return "ok"; + } + } + + /** Stands in for a wrapper that implements the method by delegating it. */ + static class Delegating extends Unimplemented { + @Override + public String m() { + return "delegated"; + } + } + + @Test + void attributesAnErrorWithoutAMessageByTheClassItself() { + // JDK 8 gives no message once the call site has seen a class that implements the method + AbstractMethodError noMessage = new AbstractMethodError(); + assertTrue(ClassLatch.isNamedIn(noMessage, Unimplemented.class, "m")); + // the class implements this method, so an error from it must come from elsewhere + assertFalse(ClassLatch.isNamedIn(noMessage, Unimplemented.class, "implemented")); + // a delegating wrapper is not blamed for its delegate's error + assertFalse(ClassLatch.isNamedIn(noMessage, Delegating.class, "m")); + assertFalse(ClassLatch.isNamedIn(noMessage, Unimplemented.class, "missing")); + } + + /** Stands in for a type that is missing from the class path at run time. */ + public static class Missing {} + + /** A class with a public signature that references {@link Missing}. */ + public static class ReferencesMissing { + public Missing m() { + return null; + } + } + + @Test + void anUnresolvableSignatureMeansCannotTellNotAnEscapingError() throws Exception { + Class type = withoutMissing().loadClass(ReferencesMissing.class.getName()); + // getMethods() resolves every public signature, so the missing return type fails it + assertThrows(NoClassDefFoundError.class, type::getMethods); + + assertFalse(ClassLatch.isNamedIn(new AbstractMethodError(), type, "m")); + // the answer is cached, so a second failure neither rescans nor throws + assertFalse(ClassLatch.isNamedIn(new AbstractMethodError(), type, "m")); + } + + /** Defines {@link ReferencesMissing} itself, and refuses to load {@link Missing}. */ + private static ClassLoader withoutMissing() { + return new ClassLoader(ClassLatchTest.class.getClassLoader()) { + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.equals(Missing.class.getName())) { + throw new ClassNotFoundException(name); + } + if (!name.equals(ReferencesMissing.class.getName())) { + return super.loadClass(name, resolve); + } + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + if (loaded == null) { + byte[] bytes = classBytes(name); + loaded = defineClass(name, bytes, 0, bytes.length); + } + return loaded; + } + } + }; + } + + private static byte[] classBytes(String name) throws ClassNotFoundException { + try (InputStream in = + ClassLatchTest.class + .getClassLoader() + .getResourceAsStream(name.replace('.', '/') + ".class")) { + if (in == null) { + throw new ClassNotFoundException(name); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + for (int read; (read = in.read(buffer)) != -1; ) { + out.write(buffer, 0, read); + } + return out.toByteArray(); + } catch (IOException e) { + throw new ClassNotFoundException(name, e); + } + } + + @Test + void latchIfNamedLatchesOnAnErrorWithoutAMessage() { + final boolean[] result = new boolean[1]; + ClassLatch latch = + new ClassLatch() { + @Override + protected Class keyOf(Object target) { + return Unimplemented.class; + } + + @Override + protected String apply(Object target) { + result[0] = latchIfNamed(target, "m", new AbstractMethodError()); + return "unnamed"; + } + }; + latch.tryApply("x"); + assertTrue(result[0]); + assertTrue(latch.isLatched("x")); + } + @Test void checkedExceptionsPropagateWithoutLatching() { ClassLatch latch =