From bb4e5331ef79f7f68f0407557d0b74f5cec73f1a Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 28 Sep 2026 16:41:11 -0400 Subject: [PATCH 01/14] Stop repeated NoSuchFieldError in Jackson 2.16 interner lookup JsonParser216Helper reads package-private Jackson fields. On classpaths where they are missing (e.g. a mixed or repackaged Jackson) every getCurrentName() call threw and was reported. Rethrow the first NoSuchFieldError per class loader so it is still reported once, then assume interned field names. Assuming "interned" skips tainting the field name, trading a possible false negative for avoiding false positives from tainting shared interned strings. Co-Authored-By: Claude Sonnet 5.5 --- .../core/json/JsonParser216Helper.java | 36 ++++- .../core/JsonParser216HelperTest.java | 131 ++++++++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/test/java/datadog/trace/instrumentation/jackson_2_16/core/JsonParser216HelperTest.java diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java index d2ef89cc015..c35ffee451f 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java @@ -2,10 +2,44 @@ import com.fasterxml.jackson.core.sym.ByteQuadsCanonicalizer216Helper; +/** + * Reads whether a {@link UTF8StreamJsonParser} interns its field names. + * + *

This reads package-private Jackson fields ({@code _symbols}, {@code _interner}). Stock + * jackson-core 2.16+ always has them, but a classpath that mixes Jackson builds can lack them, + * which surfaces as a {@link NoSuchFieldError}. + * + *

IAST design note: when the fields are missing we cannot tell whether names are + * interned, and we answer {@code true} ("interned"). The caller then records the current field name + * but does not taint the name string. This is a deliberate trade-off: + * + *

+ * + *

The first failure per class loader is rethrown so the instrumentation exception handler still + * reports it once. After that the failure is remembered and calls return {@code true} without + * throwing, so a broken classpath does not cost an exception per parsed field name. + */ public final class JsonParser216Helper { + private static volatile boolean fieldsUnavailable; + private JsonParser216Helper() {} public static boolean fetchInterner(UTF8StreamJsonParser jsonParser) { - return ByteQuadsCanonicalizer216Helper.fetchInterner(jsonParser._symbols); + if (fieldsUnavailable) { + return true; + } + try { + return ByteQuadsCanonicalizer216Helper.fetchInterner(jsonParser._symbols); + } catch (NoSuchFieldError e) { + fieldsUnavailable = true; + throw e; + } } } diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/test/java/datadog/trace/instrumentation/jackson_2_16/core/JsonParser216HelperTest.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/test/java/datadog/trace/instrumentation/jackson_2_16/core/JsonParser216HelperTest.java new file mode 100644 index 00000000000..dbdc53973e5 --- /dev/null +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/test/java/datadog/trace/instrumentation/jackson_2_16/core/JsonParser216HelperTest.java @@ -0,0 +1,131 @@ +package datadog.trace.instrumentation.jackson_2_16.core; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.json.JsonParser216Helper; +import com.fasterxml.jackson.core.json.UTF8StreamJsonParser; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import net.bytebuddy.jar.asm.ClassReader; +import net.bytebuddy.jar.asm.ClassWriter; +import net.bytebuddy.jar.asm.commons.ClassRemapper; +import net.bytebuddy.jar.asm.commons.SimpleRemapper; +import org.junit.jupiter.api.Test; + +class JsonParser216HelperTest { + + private static final String JACKSON_CORE_PREFIX = "com.fasterxml.jackson.core."; + private static final String CANONICALIZER = + "com.fasterxml.jackson.core.sym.ByteQuadsCanonicalizer"; + + @Test + void reportsInternedFieldNames() throws Exception { + JsonFactory factory = new JsonFactory().enable(JsonFactory.Feature.INTERN_FIELD_NAMES); + UTF8StreamJsonParser parser = (UTF8StreamJsonParser) factory.createParser(json()); + + assertTrue(JsonParser216Helper.fetchInterner(parser)); + } + + @Test + void reportsNonInternedFieldNames() throws Exception { + JsonFactory factory = new JsonFactory().disable(JsonFactory.Feature.INTERN_FIELD_NAMES); + UTF8StreamJsonParser parser = (UTF8StreamJsonParser) factory.createParser(json()); + + assertFalse(JsonParser216Helper.fetchInterner(parser)); + } + + /** + * Simulates a classpath whose {@code ByteQuadsCanonicalizer} has no {@code _interner} field. The + * first failure is rethrown so it is reported once; later calls assume interned names. + */ + @Test + void rethrowsFirstMissingFieldThenAssumesInterned() throws Exception { + ClassLoader loader = new MissingInternerClassLoader(); + Object factory = loader.loadClass(JsonFactory.class.getName()).getConstructor().newInstance(); + Object parser = + factory.getClass().getMethod("createParser", byte[].class).invoke(factory, (Object) json()); + Method fetchInterner = + loader + .loadClass(JsonParser216Helper.class.getName()) + .getMethod("fetchInterner", loader.loadClass(UTF8StreamJsonParser.class.getName())); + + InvocationTargetException first = + assertThrows(InvocationTargetException.class, () -> fetchInterner.invoke(null, parser)); + assertInstanceOf(NoSuchFieldError.class, first.getCause()); + + assertTrue((boolean) fetchInterner.invoke(null, parser)); + assertTrue((boolean) fetchInterner.invoke(null, parser)); + } + + private static byte[] json() { + return "{\"name\":\"value\"}".getBytes(UTF_8); + } + + /** + * Loads jackson-core child-first, renaming {@code ByteQuadsCanonicalizer._interner} in the + * bytecode. The class stays self-consistent, but a lookup of the original field name fails with + * {@link NoSuchFieldError}, like a mixed or repackaged Jackson on the classpath. + */ + private static final class MissingInternerClassLoader extends ClassLoader { + MissingInternerClassLoader() { + super(JsonParser216HelperTest.class.getClassLoader()); + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!name.startsWith(JACKSON_CORE_PREFIX)) { + return super.loadClass(name, resolve); + } + synchronized (getClassLoadingLock(name)) { + Class clazz = findLoadedClass(name); + if (clazz == null) { + clazz = define(name); + } + if (resolve) { + resolveClass(clazz); + } + return clazz; + } + } + + private Class define(String name) throws ClassNotFoundException { + try (InputStream in = getParent().getResourceAsStream(name.replace('.', '/') + ".class")) { + if (in == null) { + throw new ClassNotFoundException(name); + } + byte[] bytes = readAll(in); + if (name.equals(CANONICALIZER)) { + bytes = renameInterner(bytes); + } + return defineClass(name, bytes, 0, bytes.length); + } catch (IOException e) { + throw new ClassNotFoundException(name, e); + } + } + + private static byte[] renameInterner(byte[] bytes) { + ClassWriter writer = new ClassWriter(0); + SimpleRemapper remapper = + new SimpleRemapper(CANONICALIZER.replace('.', '/') + "._interner", "_interner_renamed"); + new ClassReader(bytes).accept(new ClassRemapper(writer, remapper), 0); + return writer.toByteArray(); + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + for (int read = in.read(buffer); read != -1; read = in.read(buffer)) { + out.write(buffer, 0, read); + } + return out.toByteArray(); + } + } +} From 630f077a16f107227b1de2564d9606eddfa8302d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 30 Sep 2026 14:49:28 -0400 Subject: [PATCH 02/14] Latch each Jackson field read instead of sharing one flag Replace the shared static volatile flag with a Latch per field read: _symbols in JsonParser216Helper and _interner in ByteQuadsCanonicalizer216Helper. A classpath missing only one of the fields keeps using the other, and each field's first failure is rethrown and reported once. Adds Latch, a one-way call-site-wide latch (plain flag, hint semantics), with its tests, and a test for each missing field. Co-Authored-By: Claude Sonnet 5.5 --- .../core/json/JsonParser216Helper.java | 38 +++--- .../sym/ByteQuadsCanonicalizer216Helper.java | 32 ++++- .../core/JsonParser216HelperTest.java | 39 ++++-- .../main/java/datadog/trace/util/Latch.java | 58 +++++++++ .../java/datadog/trace/util/LatchTest.java | 115 ++++++++++++++++++ 5 files changed, 256 insertions(+), 26 deletions(-) create mode 100644 internal-api/src/main/java/datadog/trace/util/Latch.java create mode 100644 internal-api/src/test/java/datadog/trace/util/LatchTest.java diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java index c35ffee451f..ee690109afd 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java @@ -1,6 +1,8 @@ package com.fasterxml.jackson.core.json; +import com.fasterxml.jackson.core.sym.ByteQuadsCanonicalizer; import com.fasterxml.jackson.core.sym.ByteQuadsCanonicalizer216Helper; +import datadog.trace.util.Latch; /** * Reads whether a {@link UTF8StreamJsonParser} interns its field names. @@ -22,24 +24,32 @@ * name. * * - *

The first failure per class loader is rethrown so the instrumentation exception handler still - * reports it once. After that the failure is remembered and calls return {@code true} without - * throwing, so a broken classpath does not cost an exception per parsed field name. + *

Each field read has its own {@link Latch}, here for {@code _symbols} and in {@link + * ByteQuadsCanonicalizer216Helper} for {@code _interner}, so a classpath missing only one of them + * keeps using the other. The first failure of each is rethrown so the instrumentation exception + * handler still reports it once. After that the failure is remembered and calls return {@code true} + * without throwing, so a broken classpath does not cost an exception per parsed field name. */ public final class JsonParser216Helper { - private static volatile boolean fieldsUnavailable; - private JsonParser216Helper() {} + private static final Latch + SYMBOLS = + new Latch() { + @Override + protected ByteQuadsCanonicalizer get(UTF8StreamJsonParser jsonParser) { + try { + return jsonParser._symbols; + } catch (NoSuchFieldError e) { + latch(); + throw e; + } + } + }; + public static boolean fetchInterner(UTF8StreamJsonParser jsonParser) { - if (fieldsUnavailable) { - return true; - } - try { - return ByteQuadsCanonicalizer216Helper.fetchInterner(jsonParser._symbols); - } catch (NoSuchFieldError e) { - fieldsUnavailable = true; - throw e; - } + ByteQuadsCanonicalizer symbols = SYMBOLS.getOrDefault(jsonParser); + // no symbol table to ask: assume interned (see the class comment) + return symbols == null || ByteQuadsCanonicalizer216Helper.fetchInterner(symbols); } } diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java index 7c3a6794650..09c4bc6b506 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java @@ -1,9 +1,39 @@ package com.fasterxml.jackson.core.sym; +import datadog.trace.util.Latch; + +/** + * Reads whether a {@link ByteQuadsCanonicalizer} interns its field names, from the package-private + * {@code _interner} field. + * + *

A classpath that mixes Jackson builds can lack the field, which surfaces as a {@link + * NoSuchFieldError}. That is the same for every canonicalizer, so a single {@link Latch} covers the + * read: the first failure is rethrown, so the instrumentation exception handler still reports it + * once, and afterwards the answer is {@code true} ("interned") without throwing. See {@code + * JsonParser216Helper} for why "interned" is the default. + */ public final class ByteQuadsCanonicalizer216Helper { private ByteQuadsCanonicalizer216Helper() {} + private static final Latch INTERNER = + new Latch() { + @Override + protected Boolean get(ByteQuadsCanonicalizer symbols) { + try { + return symbols._interner != null; + } catch (NoSuchFieldError e) { + latch(); + throw e; + } + } + + @Override + protected Boolean defaultValue(ByteQuadsCanonicalizer symbols) { + return Boolean.TRUE; + } + }; + public static boolean fetchInterner(ByteQuadsCanonicalizer symbols) { - return symbols._interner != null; + return INTERNER.getOrDefault(symbols); } } diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/test/java/datadog/trace/instrumentation/jackson_2_16/core/JsonParser216HelperTest.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/test/java/datadog/trace/instrumentation/jackson_2_16/core/JsonParser216HelperTest.java index dbdc53973e5..bbc103d7da4 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/test/java/datadog/trace/instrumentation/jackson_2_16/core/JsonParser216HelperTest.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/test/java/datadog/trace/instrumentation/jackson_2_16/core/JsonParser216HelperTest.java @@ -25,6 +25,7 @@ class JsonParser216HelperTest { private static final String JACKSON_CORE_PREFIX = "com.fasterxml.jackson.core."; private static final String CANONICALIZER = "com.fasterxml.jackson.core.sym.ByteQuadsCanonicalizer"; + private static final String UTF8_PARSER = "com.fasterxml.jackson.core.json.UTF8StreamJsonParser"; @Test void reportsInternedFieldNames() throws Exception { @@ -47,8 +48,19 @@ void reportsNonInternedFieldNames() throws Exception { * first failure is rethrown so it is reported once; later calls assume interned names. */ @Test - void rethrowsFirstMissingFieldThenAssumesInterned() throws Exception { - ClassLoader loader = new MissingInternerClassLoader(); + void rethrowsFirstMissingInternerThenAssumesInterned() throws Exception { + assertRethrowsOnceThenAssumesInterned(CANONICALIZER, "_interner"); + } + + /** The same for the parser's {@code _symbols} field, which has its own latch. */ + @Test + void rethrowsFirstMissingSymbolsThenAssumesInterned() throws Exception { + assertRethrowsOnceThenAssumesInterned(UTF8_PARSER, "_symbols"); + } + + private void assertRethrowsOnceThenAssumesInterned(String className, String missingField) + throws Exception { + ClassLoader loader = new MissingFieldClassLoader(className, missingField); Object factory = loader.loadClass(JsonFactory.class.getName()).getConstructor().newInstance(); Object parser = factory.getClass().getMethod("createParser", byte[].class).invoke(factory, (Object) json()); @@ -70,13 +82,18 @@ private static byte[] json() { } /** - * Loads jackson-core child-first, renaming {@code ByteQuadsCanonicalizer._interner} in the - * bytecode. The class stays self-consistent, but a lookup of the original field name fails with - * {@link NoSuchFieldError}, like a mixed or repackaged Jackson on the classpath. + * Loads jackson-core child-first, renaming one field of one class in the bytecode. The class + * stays self-consistent, but a lookup of the original field name fails with {@link + * NoSuchFieldError}, like a mixed or repackaged Jackson on the classpath. */ - private static final class MissingInternerClassLoader extends ClassLoader { - MissingInternerClassLoader() { + private static final class MissingFieldClassLoader extends ClassLoader { + private final String className; + private final String field; + + MissingFieldClassLoader(String className, String field) { super(JsonParser216HelperTest.class.getClassLoader()); + this.className = className; + this.field = field; } @Override @@ -102,8 +119,8 @@ private Class define(String name) throws ClassNotFoundException { throw new ClassNotFoundException(name); } byte[] bytes = readAll(in); - if (name.equals(CANONICALIZER)) { - bytes = renameInterner(bytes); + if (name.equals(className)) { + bytes = renameField(bytes); } return defineClass(name, bytes, 0, bytes.length); } catch (IOException e) { @@ -111,10 +128,10 @@ private Class define(String name) throws ClassNotFoundException { } } - private static byte[] renameInterner(byte[] bytes) { + private byte[] renameField(byte[] bytes) { ClassWriter writer = new ClassWriter(0); SimpleRemapper remapper = - new SimpleRemapper(CANONICALIZER.replace('.', '/') + "._interner", "_interner_renamed"); + new SimpleRemapper(className.replace('.', '/') + "." + field, field + "_renamed"); new ClassReader(bytes).accept(new ClassRemapper(writer, remapper), 0); return writer.toByteArray(); } diff --git a/internal-api/src/main/java/datadog/trace/util/Latch.java b/internal-api/src/main/java/datadog/trace/util/Latch.java new file mode 100644 index 00000000000..d345e39113a --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/util/Latch.java @@ -0,0 +1,58 @@ +package datadog.trace.util; + +import javax.annotation.Nullable; + +/** + * A one-way, call-site-wide latch for an operation that fails the same way for everyone once it has + * failed, such as reading a field that is missing from the classes on the classpath. For a failure + * that depends on the receiver's class, needs per-class state, which this does not keep. + * + *

Intended as a {@code static final} anonymous subclass, one per call site: the receiver is then + * a constant of a known exact type, so the JIT can inline {@link #get} and {@link #defaultValue}. + * Subclasses decide what counts as a failure in their own {@code try/catch} inside {@link #get}, so + * checked exceptions and a tight {@code try} scope come for free, and call {@link #latch()} + * themselves. + * + *

This is a hint, not a lock. The flag is deliberately plain. A stale read only costs another + * failure; a thread always sees its own write, so each thread pays for at most one failure after + * its own first. Other threads' writes become visible eventually, with no bound on how long that + * takes. + * + * @param the type of the value the operation is applied to + * @param the type of the result + * @param the checked exception {@link #get} may throw + */ +public abstract class Latch { + private boolean latched; + + /** Performs the operation. Call {@link #latch()} when it has failed in a way that will recur. */ + @Nullable + protected abstract R get(T target) throws E; + + /** The result once latched. {@code null} unless overridden. */ + @Nullable + protected R defaultValue(T target) { + return null; + } + + /** Performs the operation unless latched, in which case returns {@link #defaultValue}. */ + @Nullable + public final R getOrDefault(T target) throws E { + return latched ? defaultValue(target) : get(target); + } + + /** Returns whether the operation is being skipped. */ + public final boolean isLatched() { + return latched; + } + + /** Skips the operation from now on. */ + protected final void latch() { + latched = true; + } + + /** Resumes performing the operation, for tests or for a policy that retries. */ + protected final void unlatch() { + latched = false; + } +} diff --git a/internal-api/src/test/java/datadog/trace/util/LatchTest.java b/internal-api/src/test/java/datadog/trace/util/LatchTest.java new file mode 100644 index 00000000000..eee3c29a795 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/LatchTest.java @@ -0,0 +1,115 @@ +package datadog.trace.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.SQLException; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class LatchTest { + + /** The shape of a missing-field read: rethrow the first failure, then answer a default. */ + private static final class FieldLatch extends Latch { + final AtomicInteger calls = new AtomicInteger(); + boolean fieldPresent; + + @Override + protected Boolean get(String target) { + calls.incrementAndGet(); + try { + if (!fieldPresent) { + throw new NoSuchFieldError("_interner"); + } + return false; + } catch (NoSuchFieldError e) { + latch(); + throw e; + } + } + + @Override + protected Boolean defaultValue(String target) { + return Boolean.TRUE; + } + } + + @Test + void performsTheOperationUntilLatched() { + FieldLatch latch = new FieldLatch(); + latch.fieldPresent = true; + + assertEquals(false, latch.getOrDefault("x")); + assertEquals(false, latch.getOrDefault("x")); + + assertEquals(2, latch.calls.get()); + assertFalse(latch.isLatched()); + } + + @Test + void rethrowsTheFirstFailureThenAnswersTheDefaultWithoutCalling() { + FieldLatch latch = new FieldLatch(); + + assertThrows(NoSuchFieldError.class, () -> latch.getOrDefault("x")); + assertTrue(latch.isLatched()); + + assertEquals(true, latch.getOrDefault("x")); + assertEquals(true, latch.getOrDefault("y")); + assertEquals(1, latch.calls.get(), "later calls should be skipped"); + } + + @Test + void defaultsToNull() { + Latch latch = + new Latch() { + @Override + protected String get(String target) { + latch(); + return "first"; + } + }; + + assertEquals("first", latch.getOrDefault("x")); + assertNull(latch.getOrDefault("x")); + } + + @Test + void unlatchResumesTheOperation() { + Latch latch = + new Latch() { + @Override + protected String get(String target) { + latch(); + return "called"; + } + + @Override + protected String defaultValue(String target) { + unlatch(); + return "skipped"; + } + }; + + assertEquals("called", latch.getOrDefault("x")); + assertEquals("skipped", latch.getOrDefault("x")); + assertFalse(latch.isLatched()); + assertEquals("called", latch.getOrDefault("x")); + } + + @Test + void checkedExceptionsPropagate() { + Latch latch = + new Latch() { + @Override + protected String get(String target) throws SQLException { + throw new SQLException("boom"); + } + }; + + assertThrows(SQLException.class, () -> latch.getOrDefault("x")); + assertFalse(latch.isLatched()); + } +} From 092eaf0e8e2cd9f3ad8cde4c9bd4c33ee9e0879e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 30 Sep 2026 14:58:33 -0400 Subject: [PATCH 03/14] Address review: state the failure bound, note the aborted first call - Reword the Javadoc: a failure is reported at least once, bounded by concurrency, not exactly once; threads racing the first failure each rethrow. The aim is to stop throwing forever, not to report once. - Document that the call hitting the failure is aborted by the advice's exception suppression before setCurrentName, so that one field name is not tracked. - Test that a second class loader rethrows its own first failure (the latch state is per class loader). Co-Authored-By: Claude Sonnet 5.5 --- .../jackson/core/json/JsonParser216Helper.java | 10 ++++++++-- .../core/sym/ByteQuadsCanonicalizer216Helper.java | 3 ++- .../jackson_2_16/core/JsonParser216HelperTest.java | 10 ++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java index ee690109afd..186921ada58 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java @@ -27,8 +27,14 @@ *

Each field read has its own {@link Latch}, here for {@code _symbols} and in {@link * ByteQuadsCanonicalizer216Helper} for {@code _interner}, so a classpath missing only one of them * keeps using the other. The first failure of each is rethrown so the instrumentation exception - * handler still reports it once. After that the failure is remembered and calls return {@code true} - * without throwing, so a broken classpath does not cost an exception per parsed field name. + * handler still reports it. This is not an exactly-once guarantee: threads that race the first + * failure each rethrow, so a failure is reported at least once, bounded by concurrency. After that + * the failure is remembered and calls return {@code true} without throwing, so a broken classpath + * does not cost an exception per parsed field name. + * + *

The call that hits the failure is aborted by the advice's exception suppression before it + * reaches {@code setCurrentName}, so that one field name is not tracked and a value read right + * after it may be attributed to no name, or to the previous one. Later calls are not affected. */ public final class JsonParser216Helper { private JsonParser216Helper() {} diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java index 09c4bc6b506..460c664aaf4 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java @@ -9,7 +9,8 @@ *

A classpath that mixes Jackson builds can lack the field, which surfaces as a {@link * NoSuchFieldError}. That is the same for every canonicalizer, so a single {@link Latch} covers the * read: the first failure is rethrown, so the instrumentation exception handler still reports it - * once, and afterwards the answer is {@code true} ("interned") without throwing. See {@code + * (at least once, bounded by concurrency, since threads racing the first failure each rethrow), and + * afterwards the answer is {@code true} ("interned") without throwing. See {@code * JsonParser216Helper} for why "interned" is the default. */ public final class ByteQuadsCanonicalizer216Helper { diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/test/java/datadog/trace/instrumentation/jackson_2_16/core/JsonParser216HelperTest.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/test/java/datadog/trace/instrumentation/jackson_2_16/core/JsonParser216HelperTest.java index bbc103d7da4..3c526d3a2b6 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/test/java/datadog/trace/instrumentation/jackson_2_16/core/JsonParser216HelperTest.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/test/java/datadog/trace/instrumentation/jackson_2_16/core/JsonParser216HelperTest.java @@ -58,6 +58,16 @@ void rethrowsFirstMissingSymbolsThenAssumesInterned() throws Exception { assertRethrowsOnceThenAssumesInterned(UTF8_PARSER, "_symbols"); } + /** + * The latch state is static, so it is per class loader: a second loader with the same problem + * must rethrow its own first failure, not inherit the first loader's latch. + */ + @Test + void eachClassLoaderRethrowsItsOwnFirstFailure() throws Exception { + assertRethrowsOnceThenAssumesInterned(CANONICALIZER, "_interner"); + assertRethrowsOnceThenAssumesInterned(CANONICALIZER, "_interner"); + } + private void assertRethrowsOnceThenAssumesInterned(String className, String missingField) throws Exception { ClassLoader loader = new MissingFieldClassLoader(className, missingField); From e1449570fc00d0eae3d0d7abe705c118532f1f04 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 30 Sep 2026 15:04:03 -0400 Subject: [PATCH 04/14] Let the caller choose the fallback: tryGetOrNull and tryGetOrDefault Drop the defaultValue hook from Latch. The public methods are now tryGetOrNull (skip if latched, otherwise get; null means nothing is available) and tryGetOrDefault (null-coalescing sugar over it), so a call that yields nothing and a skipped call always agree. The protected hook stays get. A null return needs no allocation and no escape analysis, unlike a wrapper result type. Co-Authored-By: Claude Sonnet 5.5 --- .../core/json/JsonParser216Helper.java | 2 +- .../sym/ByteQuadsCanonicalizer216Helper.java | 7 +- .../main/java/datadog/trace/util/Latch.java | 32 ++++--- .../java/datadog/trace/util/LatchTest.java | 89 ++++++++++++------- 4 files changed, 77 insertions(+), 53 deletions(-) diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java index 186921ada58..8cf03c4a591 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java @@ -54,7 +54,7 @@ protected ByteQuadsCanonicalizer get(UTF8StreamJsonParser jsonParser) { }; public static boolean fetchInterner(UTF8StreamJsonParser jsonParser) { - ByteQuadsCanonicalizer symbols = SYMBOLS.getOrDefault(jsonParser); + ByteQuadsCanonicalizer symbols = SYMBOLS.tryGetOrNull(jsonParser); // no symbol table to ask: assume interned (see the class comment) return symbols == null || ByteQuadsCanonicalizer216Helper.fetchInterner(symbols); } diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java index 460c664aaf4..a2c54227df6 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java @@ -27,14 +27,9 @@ protected Boolean get(ByteQuadsCanonicalizer symbols) { throw e; } } - - @Override - protected Boolean defaultValue(ByteQuadsCanonicalizer symbols) { - return Boolean.TRUE; - } }; public static boolean fetchInterner(ByteQuadsCanonicalizer symbols) { - return INTERNER.getOrDefault(symbols); + return INTERNER.tryGetOrDefault(symbols, Boolean.TRUE); } } diff --git a/internal-api/src/main/java/datadog/trace/util/Latch.java b/internal-api/src/main/java/datadog/trace/util/Latch.java index d345e39113a..cccbb21bb25 100644 --- a/internal-api/src/main/java/datadog/trace/util/Latch.java +++ b/internal-api/src/main/java/datadog/trace/util/Latch.java @@ -4,14 +4,13 @@ /** * A one-way, call-site-wide latch for an operation that fails the same way for everyone once it has - * failed, such as reading a field that is missing from the classes on the classpath. For a failure - * that depends on the receiver's class, needs per-class state, which this does not keep. + * failed, such as reading a field that is missing from the classes on the classpath. A failure that + * depends on the receiver's class needs per-class state, which this does not keep. * *

Intended as a {@code static final} anonymous subclass, one per call site: the receiver is then - * a constant of a known exact type, so the JIT can inline {@link #get} and {@link #defaultValue}. - * Subclasses decide what counts as a failure in their own {@code try/catch} inside {@link #get}, so - * checked exceptions and a tight {@code try} scope come for free, and call {@link #latch()} - * themselves. + * a constant of a known exact type, so the JIT can inline {@link #get}. Subclasses decide what + * counts as a failure in their own {@code try/catch} inside {@link #get}, so checked exceptions and + * a tight {@code try} scope come for free, and call {@link #latch()} themselves. * *

This is a hint, not a lock. The flag is deliberately plain. A stale read only costs another * failure; a thread always sees its own write, so each thread pays for at most one failure after @@ -29,16 +28,23 @@ public abstract class Latch { @Nullable protected abstract R get(T target) throws E; - /** The result once latched. {@code null} unless overridden. */ + /** + * Performs the operation unless latched, in which case returns {@code null}. A {@code null} + * result means nothing is available: the operation was skipped, or it produced no value. + */ @Nullable - protected R defaultValue(T target) { - return null; + public final R tryGetOrNull(T target) throws E { + return latched ? null : get(target); } - /** Performs the operation unless latched, in which case returns {@link #defaultValue}. */ - @Nullable - public final R getOrDefault(T target) throws E { - return latched ? defaultValue(target) : get(target); + /** + * Like {@link #tryGetOrNull}, but returns {@code fallback} when there is nothing available. The + * fallback is also used when the operation itself produced {@code null}, so a call and a skipped + * call always agree. + */ + public final R tryGetOrDefault(T target, R fallback) throws E { + final R result = tryGetOrNull(target); + return result != null ? result : fallback; } /** Returns whether the operation is being skipped. */ diff --git a/internal-api/src/test/java/datadog/trace/util/LatchTest.java b/internal-api/src/test/java/datadog/trace/util/LatchTest.java index eee3c29a795..8e6280c223b 100644 --- a/internal-api/src/test/java/datadog/trace/util/LatchTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LatchTest.java @@ -12,7 +12,7 @@ class LatchTest { - /** The shape of a missing-field read: rethrow the first failure, then answer a default. */ + /** The shape of a missing-field read: rethrow the first failure, then skip the read. */ private static final class FieldLatch extends Latch { final AtomicInteger calls = new AtomicInteger(); boolean fieldPresent; @@ -30,11 +30,6 @@ protected Boolean get(String target) { throw e; } } - - @Override - protected Boolean defaultValue(String target) { - return Boolean.TRUE; - } } @Test @@ -42,61 +37,89 @@ void performsTheOperationUntilLatched() { FieldLatch latch = new FieldLatch(); latch.fieldPresent = true; - assertEquals(false, latch.getOrDefault("x")); - assertEquals(false, latch.getOrDefault("x")); + assertEquals(false, latch.tryGetOrNull("x")); + assertEquals(false, latch.tryGetOrNull("x")); assertEquals(2, latch.calls.get()); assertFalse(latch.isLatched()); } @Test - void rethrowsTheFirstFailureThenAnswersTheDefaultWithoutCalling() { + void rethrowsTheFirstFailureThenSkipsTheOperation() { FieldLatch latch = new FieldLatch(); - assertThrows(NoSuchFieldError.class, () -> latch.getOrDefault("x")); + assertThrows(NoSuchFieldError.class, () -> latch.tryGetOrNull("x")); assertTrue(latch.isLatched()); - assertEquals(true, latch.getOrDefault("x")); - assertEquals(true, latch.getOrDefault("y")); + assertNull(latch.tryGetOrNull("x")); + assertNull(latch.tryGetOrNull("y")); + assertEquals(1, latch.calls.get(), "later calls should be skipped"); + } + + @Test + void tryGetOrDefaultReturnsTheResultWhenThereIsOne() { + FieldLatch latch = new FieldLatch(); + latch.fieldPresent = true; + + // a real false must not be replaced by the fallback + assertEquals(false, latch.tryGetOrDefault("x", Boolean.TRUE)); + } + + @Test + void tryGetOrDefaultReturnsTheFallbackOnceLatched() { + FieldLatch latch = new FieldLatch(); + assertThrows(NoSuchFieldError.class, () -> latch.tryGetOrDefault("x", Boolean.TRUE)); + + assertEquals(true, latch.tryGetOrDefault("x", Boolean.TRUE)); + assertEquals(true, latch.tryGetOrDefault("y", Boolean.TRUE)); assertEquals(1, latch.calls.get(), "later calls should be skipped"); } @Test - void defaultsToNull() { + void aCallThatYieldsNothingAndASkippedCallAgree() { + // the first call latches and returns null; the next is skipped. Both must give the fallback. Latch latch = new Latch() { @Override protected String get(String target) { latch(); - return "first"; + return null; } }; - assertEquals("first", latch.getOrDefault("x")); - assertNull(latch.getOrDefault("x")); + assertEquals("fallback", latch.tryGetOrDefault("x", "fallback")); + assertEquals("fallback", latch.tryGetOrDefault("x", "fallback")); + } + + /** A subclass may expose {@code unlatch}, for a policy that retries. */ + private static final class Resumable extends Latch { + int calls; + + @Override + protected String get(String target) { + calls++; + latch(); + return "called"; + } + + void resume() { + unlatch(); + } } @Test void unlatchResumesTheOperation() { - Latch latch = - new Latch() { - @Override - protected String get(String target) { - latch(); - return "called"; - } + Resumable latch = new Resumable(); - @Override - protected String defaultValue(String target) { - unlatch(); - return "skipped"; - } - }; + assertEquals("called", latch.tryGetOrNull("x")); + assertNull(latch.tryGetOrNull("x")); + assertEquals(1, latch.calls); + + latch.resume(); - assertEquals("called", latch.getOrDefault("x")); - assertEquals("skipped", latch.getOrDefault("x")); assertFalse(latch.isLatched()); - assertEquals("called", latch.getOrDefault("x")); + assertEquals("called", latch.tryGetOrNull("x")); + assertEquals(2, latch.calls); } @Test @@ -109,7 +132,7 @@ protected String get(String target) throws SQLException { } }; - assertThrows(SQLException.class, () -> latch.getOrDefault("x")); + assertThrows(SQLException.class, () -> latch.tryGetOrNull("x")); assertFalse(latch.isLatched()); } } From e9f470827b4afccbb4b55afa93eba1bad3543350 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 30 Sep 2026 15:20:13 -0400 Subject: [PATCH 05/14] Add Latch.handleNoSuchField and use it for the Jackson field reads handleNoSuchField latches on NoSuchFieldError and rethrows it, so the first failure is still reported. A missing field is the same for every receiver, so it belongs on the call-site-wide Latch. A field read throws nothing checked, so the read is a plain Function. Both Jackson field reads now go through it instead of repeating the try/catch. Co-Authored-By: Claude Sonnet 5.5 --- .../core/json/JsonParser216Helper.java | 7 +-- .../sym/ByteQuadsCanonicalizer216Helper.java | 7 +-- .../main/java/datadog/trace/util/Latch.java | 26 ++++++++ .../java/datadog/trace/util/LatchTest.java | 59 +++++++++++++++++++ 4 files changed, 87 insertions(+), 12 deletions(-) diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java index 8cf03c4a591..87a02975077 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java @@ -44,12 +44,7 @@ private JsonParser216Helper() {} new Latch() { @Override protected ByteQuadsCanonicalizer get(UTF8StreamJsonParser jsonParser) { - try { - return jsonParser._symbols; - } catch (NoSuchFieldError e) { - latch(); - throw e; - } + return handleNoSuchField(jsonParser, parser -> parser._symbols); } }; diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java index a2c54227df6..b42fde49a97 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java @@ -20,12 +20,7 @@ private ByteQuadsCanonicalizer216Helper() {} new Latch() { @Override protected Boolean get(ByteQuadsCanonicalizer symbols) { - try { - return symbols._interner != null; - } catch (NoSuchFieldError e) { - latch(); - throw e; - } + return handleNoSuchField(symbols, s -> s._interner != null); } }; diff --git a/internal-api/src/main/java/datadog/trace/util/Latch.java b/internal-api/src/main/java/datadog/trace/util/Latch.java index cccbb21bb25..3977235c323 100644 --- a/internal-api/src/main/java/datadog/trace/util/Latch.java +++ b/internal-api/src/main/java/datadog/trace/util/Latch.java @@ -1,5 +1,6 @@ package datadog.trace.util; +import java.util.function.Function; import javax.annotation.Nullable; /** @@ -47,6 +48,31 @@ public final R tryGetOrDefault(T target, R fallback) throws E { return result != null ? result : fallback; } + /** + * For a read of a field that some classes on the classpath may lack: latches if the call raises + * {@link NoSuchFieldError}, then rethrows it so the first failure is still reported. A missing + * field is the same for every receiver, so one latch covers the site. Anything else propagates + * without latching. + * + *

{@code
+   * protected Boolean get(ByteQuadsCanonicalizer symbols) {
+   *   return handleNoSuchField(symbols, s -> s._interner != null);
+   * }
+   * }
+ * + * A field read throws nothing checked, so the read is a plain {@link Function}. Unlike {@code + * ClassLatch#handleAbstractMethod}, which swallows the failure, this rethrows it. + */ + @Nullable + protected final R handleNoSuchField(T target, Function read) { + try { + return read.apply(target); + } catch (NoSuchFieldError e) { + latch(); + throw e; + } + } + /** Returns whether the operation is being skipped. */ public final boolean isLatched() { return latched; diff --git a/internal-api/src/test/java/datadog/trace/util/LatchTest.java b/internal-api/src/test/java/datadog/trace/util/LatchTest.java index 8e6280c223b..df0cda37d39 100644 --- a/internal-api/src/test/java/datadog/trace/util/LatchTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LatchTest.java @@ -3,11 +3,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.sql.SQLException; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; import org.junit.jupiter.api.Test; class LatchTest { @@ -135,4 +137,61 @@ protected String get(String target) throws SQLException { assertThrows(SQLException.class, () -> latch.tryGetOrNull("x")); assertFalse(latch.isLatched()); } + + /** What a call site writes: {@code get} delegating to {@code handleNoSuchField}. */ + private static final class Handling extends Latch { + final AtomicInteger calls = new AtomicInteger(); + Function read; + + @Override + protected String get(String target) { + return handleNoSuchField( + target, + t -> { + calls.incrementAndGet(); + return read.apply(t); + }); + } + } + + @Test + void handleNoSuchFieldReturnsTheResultWithoutLatching() { + Handling latch = new Handling(); + latch.read = t -> "value"; + + assertEquals("value", latch.tryGetOrNull("x")); + assertFalse(latch.isLatched()); + } + + @Test + void handleNoSuchFieldLatchesAndRethrowsTheFirstFailure() { + Handling latch = new Handling(); + NoSuchFieldError failure = new NoSuchFieldError("_interner"); + latch.read = + t -> { + throw failure; + }; + + NoSuchFieldError thrown = assertThrows(NoSuchFieldError.class, () -> latch.tryGetOrNull("x")); + + assertSame(failure, thrown); + assertTrue(latch.isLatched()); + assertNull(latch.tryGetOrNull("x")); + assertEquals(1, latch.calls.get(), "later calls should be skipped"); + } + + @Test + void handleNoSuchFieldDoesNotLatchOnOtherFailures() { + Handling latch = new Handling(); + latch.read = + t -> { + throw new IllegalStateException("boom"); + }; + + assertThrows(IllegalStateException.class, () -> latch.tryGetOrNull("x")); + assertThrows(IllegalStateException.class, () -> latch.tryGetOrNull("x")); + + assertFalse(latch.isLatched()); + assertEquals(2, latch.calls.get()); + } } From 5cc2cb33323fde3f251cf2c7b2d1c571d5672e39 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 30 Sep 2026 15:48:43 -0400 Subject: [PATCH 06/14] Rename ByteQuadsCanonicalizer216Helper's INTERNER latch to INTERNER_LATCH Co-Authored-By: Claude Sonnet 5 --- .../jackson/core/sym/ByteQuadsCanonicalizer216Helper.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java index b42fde49a97..2e70c12f9f5 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java @@ -16,7 +16,7 @@ public final class ByteQuadsCanonicalizer216Helper { private ByteQuadsCanonicalizer216Helper() {} - private static final Latch INTERNER = + private static final Latch INTERNER_LATCH = new Latch() { @Override protected Boolean get(ByteQuadsCanonicalizer symbols) { @@ -25,6 +25,6 @@ protected Boolean get(ByteQuadsCanonicalizer symbols) { }; public static boolean fetchInterner(ByteQuadsCanonicalizer symbols) { - return INTERNER.tryGetOrDefault(symbols, Boolean.TRUE); + return INTERNER_LATCH.tryGetOrDefault(symbols, Boolean.TRUE); } } From a9727bb35cd43aaa07a980e6d50ae26cd5d4179d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 30 Sep 2026 15:50:07 -0400 Subject: [PATCH 07/14] Name the Jackson latch constants *_LATCH Rename INTERNER to INTERNER_LATCH and SYMBOLS to SYMBOLS_LATCH, so the fields say what they are. Co-Authored-By: Claude Sonnet 5.5 --- .../com/fasterxml/jackson/core/json/JsonParser216Helper.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java index 87a02975077..1ffb6f8d03c 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java @@ -40,7 +40,7 @@ public final class JsonParser216Helper { private JsonParser216Helper() {} private static final Latch - SYMBOLS = + SYMBOLS_LATCH = new Latch() { @Override protected ByteQuadsCanonicalizer get(UTF8StreamJsonParser jsonParser) { @@ -49,7 +49,7 @@ protected ByteQuadsCanonicalizer get(UTF8StreamJsonParser jsonParser) { }; public static boolean fetchInterner(UTF8StreamJsonParser jsonParser) { - ByteQuadsCanonicalizer symbols = SYMBOLS.tryGetOrNull(jsonParser); + ByteQuadsCanonicalizer symbols = SYMBOLS_LATCH.tryGetOrNull(jsonParser); // no symbol table to ask: assume interned (see the class comment) return symbols == null || ByteQuadsCanonicalizer216Helper.fetchInterner(symbols); } From 8ed7aba718066d5a241ff4cf74cb1dff0e481427 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 30 Sep 2026 17:12:58 -0400 Subject: [PATCH 08/14] Name the Latch hook handle Rename the protected hook get to handle, so it reads the same across the Latch family and does not suggest a no-arg accessor. The public methods (tryGetOrNull, tryGetOrDefault) are unchanged. Naming only. Co-Authored-By: Claude Sonnet 5.5 --- .../jackson/core/json/JsonParser216Helper.java | 2 +- .../core/sym/ByteQuadsCanonicalizer216Helper.java | 2 +- .../src/main/java/datadog/trace/util/Latch.java | 14 +++++++------- .../test/java/datadog/trace/util/LatchTest.java | 12 ++++++------ 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java index 1ffb6f8d03c..30cdc6f68d0 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java @@ -43,7 +43,7 @@ private JsonParser216Helper() {} SYMBOLS_LATCH = new Latch() { @Override - protected ByteQuadsCanonicalizer get(UTF8StreamJsonParser jsonParser) { + protected ByteQuadsCanonicalizer handle(UTF8StreamJsonParser jsonParser) { return handleNoSuchField(jsonParser, parser -> parser._symbols); } }; diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java index 2e70c12f9f5..61e639d0af0 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java @@ -19,7 +19,7 @@ private ByteQuadsCanonicalizer216Helper() {} private static final Latch INTERNER_LATCH = new Latch() { @Override - protected Boolean get(ByteQuadsCanonicalizer symbols) { + protected Boolean handle(ByteQuadsCanonicalizer symbols) { return handleNoSuchField(symbols, s -> s._interner != null); } }; diff --git a/internal-api/src/main/java/datadog/trace/util/Latch.java b/internal-api/src/main/java/datadog/trace/util/Latch.java index 3977235c323..e0c003798a4 100644 --- a/internal-api/src/main/java/datadog/trace/util/Latch.java +++ b/internal-api/src/main/java/datadog/trace/util/Latch.java @@ -9,9 +9,9 @@ * depends on the receiver's class needs per-class state, which this does not keep. * *

Intended as a {@code static final} anonymous subclass, one per call site: the receiver is then - * a constant of a known exact type, so the JIT can inline {@link #get}. Subclasses decide what - * counts as a failure in their own {@code try/catch} inside {@link #get}, so checked exceptions and - * a tight {@code try} scope come for free, and call {@link #latch()} themselves. + * a constant of a known exact type, so the JIT can inline {@link #handle}. Subclasses decide what + * counts as a failure in their own {@code try/catch} inside {@link #handle}, so checked exceptions + * and a tight {@code try} scope come for free, and call {@link #latch()} themselves. * *

This is a hint, not a lock. The flag is deliberately plain. A stale read only costs another * failure; a thread always sees its own write, so each thread pays for at most one failure after @@ -20,14 +20,14 @@ * * @param the type of the value the operation is applied to * @param the type of the result - * @param the checked exception {@link #get} may throw + * @param the checked exception {@link #handle} may throw */ public abstract class Latch { private boolean latched; /** Performs the operation. Call {@link #latch()} when it has failed in a way that will recur. */ @Nullable - protected abstract R get(T target) throws E; + protected abstract R handle(T target) throws E; /** * Performs the operation unless latched, in which case returns {@code null}. A {@code null} @@ -35,7 +35,7 @@ public abstract class Latch { */ @Nullable public final R tryGetOrNull(T target) throws E { - return latched ? null : get(target); + return latched ? null : handle(target); } /** @@ -55,7 +55,7 @@ public final R tryGetOrDefault(T target, R fallback) throws E { * without latching. * *

{@code
-   * protected Boolean get(ByteQuadsCanonicalizer symbols) {
+   * protected Boolean handle(ByteQuadsCanonicalizer symbols) {
    *   return handleNoSuchField(symbols, s -> s._interner != null);
    * }
    * }
diff --git a/internal-api/src/test/java/datadog/trace/util/LatchTest.java b/internal-api/src/test/java/datadog/trace/util/LatchTest.java index df0cda37d39..41ab44d7873 100644 --- a/internal-api/src/test/java/datadog/trace/util/LatchTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LatchTest.java @@ -20,7 +20,7 @@ private static final class FieldLatch extends Latch latch = new Latch() { @Override - protected String get(String target) { + protected String handle(String target) { latch(); return null; } @@ -98,7 +98,7 @@ private static final class Resumable extends Latch latch = new Latch() { @Override - protected String get(String target) throws SQLException { + protected String handle(String target) throws SQLException { throw new SQLException("boom"); } }; @@ -138,13 +138,13 @@ protected String get(String target) throws SQLException { assertFalse(latch.isLatched()); } - /** What a call site writes: {@code get} delegating to {@code handleNoSuchField}. */ + /** What a call site writes: {@code handle} delegating to {@code handleNoSuchField}. */ private static final class Handling extends Latch { final AtomicInteger calls = new AtomicInteger(); Function read; @Override - protected String get(String target) { + protected String handle(String target) { return handleNoSuchField( target, t -> { From 2a1c50b4079052feb4b7b899a650b46a31afb4d7 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 30 Sep 2026 17:47:20 -0400 Subject: [PATCH 09/14] Add LatchBenchmark for the field-read latch Benchmark Latch against the status quo (read and catch on every call) and against a hand-rolled volatile and plain flag, for a real NoSuchFieldError built at setup, on the missing-field and working paths and at two stack depths. Results are added to its Javadoc once run. Co-Authored-By: Claude Sonnet 5.5 --- .../datadog/trace/util/LatchBenchmark.java | 339 ++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java diff --git a/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java new file mode 100644 index 00000000000..aecdb033a29 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java @@ -0,0 +1,339 @@ +package datadog.trace.util; + +import static java.util.Collections.singletonList; + +import java.io.File; +import java.io.IOException; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * What {@link Latch} saves when a field read fails the same way every time, and what it costs on + * the path that works. + * + *

The missing field is real: {@code Reader} is built against a {@code Holder} that has a {@code + * flag} field, and run against a {@code Holder} that does not, so its {@code getfield} raises the + * JVM's own {@link NoSuchFieldError}. Every arm reaches the read through the same {@link + * MethodHandle}, and each arm has its own method so that no arm's profile is shaped by another's. + * + *

    + *
  • {@code unguarded}: the status quo -- read and catch on every call. + *
  • {@code volatileFlag}: a hand-rolled {@code static volatile boolean}, the shape this latch + * replaced in the Jackson interner lookup. + *
  • {@code plainFlag}: the same with a plain {@code static boolean}. The difference from {@code + * latch} is the cost of the abstraction itself. + *
  • {@code latch}: a {@code static final} anonymous {@link Latch} subclass. + *
+ * + * Each has a {@code Missing} form, already latched so that the steady state is measured, and a + * {@code Present} form, where the field exists and the read succeeds. The latches' flags are never + * set on the present path. + * + *

The cost of a throw grows with the depth of the stack it fills in, which is why {@code depth} + * is a parameter. At depth 50 an earlier benchmark's forks landed in different compiled states, so + * read the per-fork iterations and not only the mean. + * + *

Run with {@code ./gradlew :internal-api:jmh -Pjmh.includes=LatchBenchmark -Pjmh.profilers=gc}. + */ +@Fork(3) +@Warmup(iterations = 3) +@Measurement(iterations = 4) +@Threads(1) +@State(Scope.Benchmark) +public class LatchBenchmark { + + @Param({"0", "50"}) + int depth; + + private Path dir; + private URLClassLoader missingLoader; + private URLClassLoader presentLoader; + private Object missingTarget; + private Object presentTarget; + + /** Set in {@link #setup}; every arm and latch reads through these. */ + private static MethodHandle readMissing; + + private static MethodHandle readPresent; + + private static boolean read(MethodHandle handle, Object target) { + try { + return (boolean) handle.invokeExact(target); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable e) { + throw new IllegalStateException(e); + } + } + + private static volatile boolean volatileMissingLatched; + private static volatile boolean volatilePresentLatched; + private static boolean plainMissingLatched; + private static boolean plainPresentLatched; + + private static final Latch LATCH_MISSING = + new Latch() { + @Override + protected Boolean handle(Object target) { + try { + return read(readMissing, target); + } catch (NoSuchFieldError e) { + latch(); + throw e; + } + } + }; + + private static final Latch LATCH_PRESENT = + new Latch() { + @Override + protected Boolean handle(Object target) { + try { + return read(readPresent, target); + } catch (NoSuchFieldError e) { + latch(); + throw e; + } + } + }; + + @Setup + public void setup() throws Throwable { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) { + throw new IllegalStateException("needs a JDK to build the classes under test"); + } + dir = Files.createTempDirectory("latch-benchmark"); + Path withField = Files.createDirectory(dir.resolve("with")); + Path withoutField = Files.createDirectory(dir.resolve("without")); + + // Reader is always built against a Holder that has the field + compile(compiler, withField, null, "Holder", "public class Holder { public boolean flag; }"); + compile( + compiler, + withField, + withField, + "Reader", + "public class Reader { public static boolean read(Holder h) { return h.flag; } }"); + // ...and the missing case runs it against a Holder that does not + compile( + compiler, withoutField, null, "Holder", "public class Holder { public boolean other; }"); + + missingLoader = + new URLClassLoader( + new URL[] {withoutField.toUri().toURL(), withField.toUri().toURL()}, + LatchBenchmark.class.getClassLoader()); + presentLoader = + new URLClassLoader( + new URL[] {withField.toUri().toURL()}, LatchBenchmark.class.getClassLoader()); + + missingTarget = missingLoader.loadClass("Holder").getDeclaredConstructor().newInstance(); + presentTarget = presentLoader.loadClass("Holder").getDeclaredConstructor().newInstance(); + readMissing = handle(missingLoader); + readPresent = handle(presentLoader); + + // reach the steady state: every missing-field guard has already met the failure once + try { + read(readMissing, missingTarget); + throw new IllegalStateException("expected a NoSuchFieldError"); + } catch (NoSuchFieldError expected) { + // the field really is missing + } + try { + LATCH_MISSING.tryGetOrNull(missingTarget); + } catch (NoSuchFieldError expected) { + // the first failure is rethrown + } + volatileMissingLatched = true; + plainMissingLatched = true; + if (!LATCH_MISSING.isLatched()) { + throw new IllegalStateException("expected the latch to latch"); + } + if (LATCH_PRESENT.isLatched()) { + throw new IllegalStateException("the present latch must not be latched"); + } + } + + private static MethodHandle handle(URLClassLoader loader) throws Throwable { + Class holder = loader.loadClass("Holder"); + return MethodHandles.publicLookup() + .findStatic( + loader.loadClass("Reader"), "read", MethodType.methodType(boolean.class, holder)) + .asType(MethodType.methodType(boolean.class, Object.class)); + } + + @TearDown + public void tearDown() throws IOException { + missingLoader.close(); + presentLoader.close(); + } + + @Benchmark + public boolean unguardedMissing() { + return unguardedMissing(depth); + } + + @Benchmark + public boolean volatileFlagMissing() { + return volatileFlagMissing(depth); + } + + @Benchmark + public boolean plainFlagMissing() { + return plainFlagMissing(depth); + } + + @Benchmark + public boolean latchMissing() { + return latchMissing(depth); + } + + @Benchmark + public boolean unguardedPresent() { + return unguardedPresent(depth); + } + + @Benchmark + public boolean volatileFlagPresent() { + return volatileFlagPresent(depth); + } + + @Benchmark + public boolean plainFlagPresent() { + return plainFlagPresent(depth); + } + + @Benchmark + public boolean latchPresent() { + return latchPresent(depth); + } + + // Each arm descends on its own so that a throw has a realistic amount of stack to fill in. + + private boolean unguardedMissing(int remaining) { + if (remaining > 0) { + return unguardedMissing(remaining - 1); + } + try { + return read(readMissing, missingTarget); + } catch (NoSuchFieldError e) { + return true; + } + } + + private boolean volatileFlagMissing(int remaining) { + if (remaining > 0) { + return volatileFlagMissing(remaining - 1); + } + if (volatileMissingLatched) { + return true; + } + try { + return read(readMissing, missingTarget); + } catch (NoSuchFieldError e) { + volatileMissingLatched = true; + throw e; + } + } + + private boolean plainFlagMissing(int remaining) { + if (remaining > 0) { + return plainFlagMissing(remaining - 1); + } + if (plainMissingLatched) { + return true; + } + try { + return read(readMissing, missingTarget); + } catch (NoSuchFieldError e) { + plainMissingLatched = true; + throw e; + } + } + + private boolean latchMissing(int remaining) { + return remaining > 0 + ? latchMissing(remaining - 1) + : LATCH_MISSING.tryGetOrDefault(missingTarget, Boolean.TRUE); + } + + private boolean unguardedPresent(int remaining) { + return remaining > 0 ? unguardedPresent(remaining - 1) : read(readPresent, presentTarget); + } + + private boolean volatileFlagPresent(int remaining) { + if (remaining > 0) { + return volatileFlagPresent(remaining - 1); + } + if (volatilePresentLatched) { + return true; + } + try { + return read(readPresent, presentTarget); + } catch (NoSuchFieldError e) { + volatilePresentLatched = true; + throw e; + } + } + + private boolean plainFlagPresent(int remaining) { + if (remaining > 0) { + return plainFlagPresent(remaining - 1); + } + if (plainPresentLatched) { + return true; + } + try { + return read(readPresent, presentTarget); + } catch (NoSuchFieldError e) { + plainPresentLatched = true; + throw e; + } + } + + private boolean latchPresent(int remaining) { + return remaining > 0 + ? latchPresent(remaining - 1) + : LATCH_PRESENT.tryGetOrDefault(presentTarget, Boolean.TRUE); + } + + private static void compile( + JavaCompiler compiler, Path out, Path classpath, String name, String source) + throws IOException { + Path file = out.resolve(name + ".java"); + Files.write(file, singletonList(source), StandardCharsets.UTF_8); + int result = + classpath == null + ? compiler.run(null, null, null, "-d", out.toString(), file.toString()) + : compiler.run( + null, + null, + null, + "-cp", + classpath.toString() + File.pathSeparator, + "-d", + out.toString(), + file.toString()); + if (result != 0) { + throw new IllegalStateException("compiling " + name + " failed"); + } + } +} From 56d50f487a5d92aaa6484d2c3144160dbd5c2f33 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 30 Sep 2026 21:24:48 -0400 Subject: [PATCH 10/14] Add LatchBenchmark results Record a five-fork run on Zulu 17 (M1) in the benchmark's Javadoc: a latched skip against the status quo and against hand-rolled volatile and plain flags, on the missing-field and working paths at two stack depths. The latch matches a plain flag and a volatile flag costs more. Co-Authored-By: Claude Sonnet 5.5 --- .../datadog/trace/util/LatchBenchmark.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java index aecdb033a29..00475952640 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java @@ -52,6 +52,44 @@ * read the per-fork iterations and not only the mean. * *

Run with {@code ./gradlew :internal-api:jmh -Pjmh.includes=LatchBenchmark -Pjmh.profilers=gc}. + * + *

Results, one run: Zulu 17.0.7 (HotSpot), MacBook M1, single thread, 5 forks, on a laptop with + * normal background activity (load about 4). {@code unguarded} is the status quo, {@code + * volatileFlag} and {@code plainFlag} are hand-rolled flags, and {@code latch} is this class. JDK 8 + * and x86 are not measured. + * + *

+ * Benchmark              (depth)          ops/s     ns/op    err   B/op
+ * unguardedMissing             0        286,635    3488.8   0.4%    768
+ * volatileFlagMissing          0    409,799,340      2.44   1.2%      0
+ * plainFlagMissing             0    458,120,051      2.18   0.6%      0
+ * latchMissing                 0    458,763,738      2.18   0.8%      0
+ * unguardedPresent             0    298,248,659      3.35   0.5%      0
+ * volatileFlagPresent          0    247,533,590      4.04   0.4%      0
+ * plainFlagPresent             0    281,751,834      3.55   0.2%      0
+ * latchPresent                 0    282,939,106      3.53   0.2%      0
+ *
+ * unguardedMissing            50        196,076    5100.1   0.5%   2128
+ * volatileFlagMissing         50     33,753,591      29.6   0.5%      0
+ * plainFlagMissing            50     36,099,245      27.7   0.4%      0
+ * latchMissing                50     35,428,255      28.2   0.3%      0
+ * unguardedPresent            50     30,037,705      33.3   0.5%      0
+ * volatileFlagPresent         50     31,199,284      32.1   3.1%      0
+ * plainFlagPresent            50     25,366,102      39.4   6.2%      0
+ * latchPresent                50     31,938,175      31.3   0.9%      0
+ * 
+ * + * A latched skip costs about 2.2 ns where the status quo costs about 3.5 us at depth 0 (5.1 us at + * depth 50) and allocates 768 B (2,128 B); that is roughly 1,600 times cheaper at depth 0 and 180 + * times at depth 50. {@code Latch} is as cheap as a hand-rolled plain flag (2.18 against 2.18 ns + * skipped, 3.53 against 3.55 ns on the working path), so the abstraction costs nothing measurable. + * A volatile flag costs more: about 0.5 ns over a plain flag on the working path at depth 0 (4.04 + * against 3.55 ns) and about 0.3 ns when skipping (2.44 against 2.18 ns). + * + *

At depth 50, read only the skipped arms (28 to 30 ns, consistent across forks): the + * working-path arms span 31 to 39 ns and the plain flag, which is the same logic as {@code latch}, + * came out slowest with a 6% error and one fork at 28.9M against 24.2M to 24.8M ops/s for the + * others. That spread is JIT and recursion noise, not a difference between the designs. */ @Fork(3) @Warmup(iterations = 3) From 94c813d5d966adff2f67a60ccf0fd6e6b7b0cc77 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 30 Sep 2026 23:10:26 -0400 Subject: [PATCH 11/14] Rename the Latch hook to apply and the public methods to tryApply* Rename the protected hook handle to apply, and the public methods tryGetOrNull and tryGetOrDefault to tryApplyOrNull and tryApplyOrDefault. apply matches ThrowingFunction.apply, which the handleX helpers take, and no longer overlaps with the handleX helper names. Naming only; benchmark results are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .../core/json/JsonParser216Helper.java | 4 +- .../sym/ByteQuadsCanonicalizer216Helper.java | 4 +- .../datadog/trace/util/LatchBenchmark.java | 10 ++-- .../main/java/datadog/trace/util/Latch.java | 20 +++---- .../java/datadog/trace/util/LatchTest.java | 56 +++++++++---------- 5 files changed, 47 insertions(+), 47 deletions(-) diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java index 30cdc6f68d0..1ba93ef15de 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java @@ -43,13 +43,13 @@ private JsonParser216Helper() {} SYMBOLS_LATCH = new Latch() { @Override - protected ByteQuadsCanonicalizer handle(UTF8StreamJsonParser jsonParser) { + protected ByteQuadsCanonicalizer apply(UTF8StreamJsonParser jsonParser) { return handleNoSuchField(jsonParser, parser -> parser._symbols); } }; public static boolean fetchInterner(UTF8StreamJsonParser jsonParser) { - ByteQuadsCanonicalizer symbols = SYMBOLS_LATCH.tryGetOrNull(jsonParser); + ByteQuadsCanonicalizer symbols = SYMBOLS_LATCH.tryApplyOrNull(jsonParser); // no symbol table to ask: assume interned (see the class comment) return symbols == null || ByteQuadsCanonicalizer216Helper.fetchInterner(symbols); } diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java index 61e639d0af0..2418ef7d0ca 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java @@ -19,12 +19,12 @@ private ByteQuadsCanonicalizer216Helper() {} private static final Latch INTERNER_LATCH = new Latch() { @Override - protected Boolean handle(ByteQuadsCanonicalizer symbols) { + protected Boolean apply(ByteQuadsCanonicalizer symbols) { return handleNoSuchField(symbols, s -> s._interner != null); } }; public static boolean fetchInterner(ByteQuadsCanonicalizer symbols) { - return INTERNER_LATCH.tryGetOrDefault(symbols, Boolean.TRUE); + return INTERNER_LATCH.tryApplyOrDefault(symbols, Boolean.TRUE); } } diff --git a/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java index 00475952640..c3bd113c5f7 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java @@ -130,7 +130,7 @@ private static boolean read(MethodHandle handle, Object target) { private static final Latch LATCH_MISSING = new Latch() { @Override - protected Boolean handle(Object target) { + protected Boolean apply(Object target) { try { return read(readMissing, target); } catch (NoSuchFieldError e) { @@ -143,7 +143,7 @@ protected Boolean handle(Object target) { private static final Latch LATCH_PRESENT = new Latch() { @Override - protected Boolean handle(Object target) { + protected Boolean apply(Object target) { try { return read(readPresent, target); } catch (NoSuchFieldError e) { @@ -196,7 +196,7 @@ public void setup() throws Throwable { // the field really is missing } try { - LATCH_MISSING.tryGetOrNull(missingTarget); + LATCH_MISSING.tryApplyOrNull(missingTarget); } catch (NoSuchFieldError expected) { // the first failure is rethrown } @@ -310,7 +310,7 @@ private boolean plainFlagMissing(int remaining) { private boolean latchMissing(int remaining) { return remaining > 0 ? latchMissing(remaining - 1) - : LATCH_MISSING.tryGetOrDefault(missingTarget, Boolean.TRUE); + : LATCH_MISSING.tryApplyOrDefault(missingTarget, Boolean.TRUE); } private boolean unguardedPresent(int remaining) { @@ -350,7 +350,7 @@ private boolean plainFlagPresent(int remaining) { private boolean latchPresent(int remaining) { return remaining > 0 ? latchPresent(remaining - 1) - : LATCH_PRESENT.tryGetOrDefault(presentTarget, Boolean.TRUE); + : LATCH_PRESENT.tryApplyOrDefault(presentTarget, Boolean.TRUE); } private static void compile( diff --git a/internal-api/src/main/java/datadog/trace/util/Latch.java b/internal-api/src/main/java/datadog/trace/util/Latch.java index e0c003798a4..ee86a0e1eda 100644 --- a/internal-api/src/main/java/datadog/trace/util/Latch.java +++ b/internal-api/src/main/java/datadog/trace/util/Latch.java @@ -9,8 +9,8 @@ * depends on the receiver's class needs per-class state, which this does not keep. * *

Intended as a {@code static final} anonymous subclass, one per call site: the receiver is then - * a constant of a known exact type, so the JIT can inline {@link #handle}. Subclasses decide what - * counts as a failure in their own {@code try/catch} inside {@link #handle}, so checked exceptions + * a constant of a known exact type, so the JIT can inline {@link #apply}. Subclasses decide what + * counts as a failure in their own {@code try/catch} inside {@link #apply}, so checked exceptions * and a tight {@code try} scope come for free, and call {@link #latch()} themselves. * *

This is a hint, not a lock. The flag is deliberately plain. A stale read only costs another @@ -20,31 +20,31 @@ * * @param the type of the value the operation is applied to * @param the type of the result - * @param the checked exception {@link #handle} may throw + * @param the checked exception {@link #apply} may throw */ public abstract class Latch { private boolean latched; /** Performs the operation. Call {@link #latch()} when it has failed in a way that will recur. */ @Nullable - protected abstract R handle(T target) throws E; + protected abstract R apply(T target) throws E; /** * Performs the operation unless latched, in which case returns {@code null}. A {@code null} * result means nothing is available: the operation was skipped, or it produced no value. */ @Nullable - public final R tryGetOrNull(T target) throws E { - return latched ? null : handle(target); + public final R tryApplyOrNull(T target) throws E { + return latched ? null : apply(target); } /** - * Like {@link #tryGetOrNull}, but returns {@code fallback} when there is nothing available. The + * Like {@link #tryApplyOrNull}, but returns {@code fallback} when there is nothing available. The * fallback is also used when the operation itself produced {@code null}, so a call and a skipped * call always agree. */ - public final R tryGetOrDefault(T target, R fallback) throws E { - final R result = tryGetOrNull(target); + public final R tryApplyOrDefault(T target, R fallback) throws E { + final R result = tryApplyOrNull(target); return result != null ? result : fallback; } @@ -55,7 +55,7 @@ public final R tryGetOrDefault(T target, R fallback) throws E { * without latching. * *

{@code
-   * protected Boolean handle(ByteQuadsCanonicalizer symbols) {
+   * protected Boolean apply(ByteQuadsCanonicalizer symbols) {
    *   return handleNoSuchField(symbols, s -> s._interner != null);
    * }
    * }
diff --git a/internal-api/src/test/java/datadog/trace/util/LatchTest.java b/internal-api/src/test/java/datadog/trace/util/LatchTest.java index 41ab44d7873..2994b8733de 100644 --- a/internal-api/src/test/java/datadog/trace/util/LatchTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LatchTest.java @@ -20,7 +20,7 @@ private static final class FieldLatch extends Latch latch.tryGetOrNull("x")); + assertThrows(NoSuchFieldError.class, () -> latch.tryApplyOrNull("x")); assertTrue(latch.isLatched()); - assertNull(latch.tryGetOrNull("x")); - assertNull(latch.tryGetOrNull("y")); + assertNull(latch.tryApplyOrNull("x")); + assertNull(latch.tryApplyOrNull("y")); assertEquals(1, latch.calls.get(), "later calls should be skipped"); } @Test - void tryGetOrDefaultReturnsTheResultWhenThereIsOne() { + void tryApplyOrDefaultReturnsTheResultWhenThereIsOne() { FieldLatch latch = new FieldLatch(); latch.fieldPresent = true; // a real false must not be replaced by the fallback - assertEquals(false, latch.tryGetOrDefault("x", Boolean.TRUE)); + assertEquals(false, latch.tryApplyOrDefault("x", Boolean.TRUE)); } @Test - void tryGetOrDefaultReturnsTheFallbackOnceLatched() { + void tryApplyOrDefaultReturnsTheFallbackOnceLatched() { FieldLatch latch = new FieldLatch(); - assertThrows(NoSuchFieldError.class, () -> latch.tryGetOrDefault("x", Boolean.TRUE)); + assertThrows(NoSuchFieldError.class, () -> latch.tryApplyOrDefault("x", Boolean.TRUE)); - assertEquals(true, latch.tryGetOrDefault("x", Boolean.TRUE)); - assertEquals(true, latch.tryGetOrDefault("y", Boolean.TRUE)); + assertEquals(true, latch.tryApplyOrDefault("x", Boolean.TRUE)); + assertEquals(true, latch.tryApplyOrDefault("y", Boolean.TRUE)); assertEquals(1, latch.calls.get(), "later calls should be skipped"); } @@ -83,14 +83,14 @@ void aCallThatYieldsNothingAndASkippedCallAgree() { Latch latch = new Latch() { @Override - protected String handle(String target) { + protected String apply(String target) { latch(); return null; } }; - assertEquals("fallback", latch.tryGetOrDefault("x", "fallback")); - assertEquals("fallback", latch.tryGetOrDefault("x", "fallback")); + assertEquals("fallback", latch.tryApplyOrDefault("x", "fallback")); + assertEquals("fallback", latch.tryApplyOrDefault("x", "fallback")); } /** A subclass may expose {@code unlatch}, for a policy that retries. */ @@ -98,7 +98,7 @@ private static final class Resumable extends Latch latch = new Latch() { @Override - protected String handle(String target) throws SQLException { + protected String apply(String target) throws SQLException { throw new SQLException("boom"); } }; - assertThrows(SQLException.class, () -> latch.tryGetOrNull("x")); + assertThrows(SQLException.class, () -> latch.tryApplyOrNull("x")); assertFalse(latch.isLatched()); } - /** What a call site writes: {@code handle} delegating to {@code handleNoSuchField}. */ + /** What a call site writes: {@code apply} delegating to {@code handleNoSuchField}. */ private static final class Handling extends Latch { final AtomicInteger calls = new AtomicInteger(); Function read; @Override - protected String handle(String target) { + protected String apply(String target) { return handleNoSuchField( target, t -> { @@ -159,7 +159,7 @@ void handleNoSuchFieldReturnsTheResultWithoutLatching() { Handling latch = new Handling(); latch.read = t -> "value"; - assertEquals("value", latch.tryGetOrNull("x")); + assertEquals("value", latch.tryApplyOrNull("x")); assertFalse(latch.isLatched()); } @@ -172,11 +172,11 @@ void handleNoSuchFieldLatchesAndRethrowsTheFirstFailure() { throw failure; }; - NoSuchFieldError thrown = assertThrows(NoSuchFieldError.class, () -> latch.tryGetOrNull("x")); + NoSuchFieldError thrown = assertThrows(NoSuchFieldError.class, () -> latch.tryApplyOrNull("x")); assertSame(failure, thrown); assertTrue(latch.isLatched()); - assertNull(latch.tryGetOrNull("x")); + assertNull(latch.tryApplyOrNull("x")); assertEquals(1, latch.calls.get(), "later calls should be skipped"); } @@ -188,8 +188,8 @@ void handleNoSuchFieldDoesNotLatchOnOtherFailures() { throw new IllegalStateException("boom"); }; - assertThrows(IllegalStateException.class, () -> latch.tryGetOrNull("x")); - assertThrows(IllegalStateException.class, () -> latch.tryGetOrNull("x")); + assertThrows(IllegalStateException.class, () -> latch.tryApplyOrNull("x")); + assertThrows(IllegalStateException.class, () -> latch.tryApplyOrNull("x")); assertFalse(latch.isLatched()); assertEquals(2, latch.calls.get()); From 05bfd5a0db791e23f8f3c9832e9d9898af06687e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 30 Sep 2026 23:15:14 -0400 Subject: [PATCH 12/14] Drop a dangling ClassLatch reference from Latch's Javadoc ClassLatch is not on this branch; describe the contrast without naming it. Co-Authored-By: Claude Sonnet 5.5 --- internal-api/src/main/java/datadog/trace/util/Latch.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/Latch.java b/internal-api/src/main/java/datadog/trace/util/Latch.java index ee86a0e1eda..79fc56e09db 100644 --- a/internal-api/src/main/java/datadog/trace/util/Latch.java +++ b/internal-api/src/main/java/datadog/trace/util/Latch.java @@ -60,8 +60,8 @@ public final R tryApplyOrDefault(T target, R fallback) throws E { * } * } * - * A field read throws nothing checked, so the read is a plain {@link Function}. Unlike {@code - * ClassLatch#handleAbstractMethod}, which swallows the failure, this rethrows it. + * A field read throws nothing checked, so the read is a plain {@link Function}. Unlike a helper + * that swallows the failure, this rethrows it, so the first failure is still reported. */ @Nullable protected final R handleNoSuchField(T target, Function read) { From da1de5fd4ab20ee089b6caa158f5fe6f1e9dfabb Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 2 Oct 2026 08:44:13 -0400 Subject: [PATCH 13/14] Add an overridable fallback to Latch Matches ClassLatch: once latched, tryApplyOrNull yields fallback(target) instead of a bare null. handleNoSuchField still rethrows the first failure so it is reported; only the skipped calls after it use the fallback. The default is null, so the Jackson latches are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../main/java/datadog/trace/util/Latch.java | 34 ++++++++----- .../java/datadog/trace/util/LatchTest.java | 48 +++++++++++++++++++ 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/Latch.java b/internal-api/src/main/java/datadog/trace/util/Latch.java index 79fc56e09db..fac0695e1d9 100644 --- a/internal-api/src/main/java/datadog/trace/util/Latch.java +++ b/internal-api/src/main/java/datadog/trace/util/Latch.java @@ -13,6 +13,10 @@ * counts as a failure in their own {@code try/catch} inside {@link #apply}, so checked exceptions * and a tight {@code try} scope come for free, and call {@link #latch()} themselves. * + *

{@link #fallback} is what every call yields once latched: {@code null} unless overridden. + * Override it when the call site has a known answer for the failed case, rather than passing the + * same default to {@link #tryApplyOrDefault} at every call. + * *

This is a hint, not a lock. The flag is deliberately plain. A stale read only costs another * failure; a thread always sees its own write, so each thread pays for at most one failure after * its own first. Other threads' writes become visible eventually, with no bound on how long that @@ -30,29 +34,37 @@ public abstract class Latch { protected abstract R apply(T target) throws E; /** - * Performs the operation unless latched, in which case returns {@code null}. A {@code null} - * result means nothing is available: the operation was skipped, or it produced no value. + * What every call yields once latched, instead of the operation. {@code null} unless overridden. + */ + @Nullable + protected R fallback(T target) throws E { + return null; + } + + /** + * Performs the operation unless latched, in which case returns {@link #fallback}. A {@code null} + * result means nothing is available: neither the operation nor the fallback produced a value. */ @Nullable public final R tryApplyOrNull(T target) throws E { - return latched ? null : apply(target); + return latched ? fallback(target) : apply(target); } /** - * Like {@link #tryApplyOrNull}, but returns {@code fallback} when there is nothing available. The - * fallback is also used when the operation itself produced {@code null}, so a call and a skipped - * call always agree. + * Like {@link #tryApplyOrNull}, but returns {@code defaultValue} when there is nothing available. + * It is also used when the operation or {@link #fallback} itself produced {@code null}, so a call + * and a skipped call always agree. */ - public final R tryApplyOrDefault(T target, R fallback) throws E { + public final R tryApplyOrDefault(T target, R defaultValue) throws E { final R result = tryApplyOrNull(target); - return result != null ? result : fallback; + return result != null ? result : defaultValue; } /** * For a read of a field that some classes on the classpath may lack: latches if the call raises - * {@link NoSuchFieldError}, then rethrows it so the first failure is still reported. A missing - * field is the same for every receiver, so one latch covers the site. Anything else propagates - * without latching. + * {@link NoSuchFieldError}, then rethrows it so the first failure is still reported; only later, + * skipped calls yield {@link #fallback}. A missing field is the same for every receiver, so one + * latch covers the site. Anything else propagates without latching. * *

{@code
    * protected Boolean apply(ByteQuadsCanonicalizer symbols) {
diff --git a/internal-api/src/test/java/datadog/trace/util/LatchTest.java b/internal-api/src/test/java/datadog/trace/util/LatchTest.java
index 2994b8733de..67e56646ef9 100644
--- a/internal-api/src/test/java/datadog/trace/util/LatchTest.java
+++ b/internal-api/src/test/java/datadog/trace/util/LatchTest.java
@@ -58,6 +58,54 @@ void rethrowsTheFirstFailureThenSkipsTheOperation() {
     assertEquals(1, latch.calls.get(), "later calls should be skipped");
   }
 
+  @Test
+  void onceLatchedEveryCallYieldsTheFallback() {
+    AtomicInteger calls = new AtomicInteger();
+    Latch latch =
+        new Latch() {
+          @Override
+          protected String apply(String target) {
+            calls.incrementAndGet();
+            return handleNoSuchField(
+                target,
+                t -> {
+                  throw new NoSuchFieldError("f");
+                });
+          }
+
+          @Override
+          protected String fallback(String target) {
+            return "fallback:" + target;
+          }
+        };
+
+    // the first failure is still rethrown, not replaced by the fallback
+    assertThrows(NoSuchFieldError.class, () -> latch.tryApplyOrNull("x"));
+
+    assertEquals("fallback:x", latch.tryApplyOrNull("x"));
+    assertEquals("fallback:y", latch.tryApplyOrDefault("y", "default"));
+    assertEquals(1, calls.get(), "later calls should be skipped");
+  }
+
+  @Test
+  void aRealNullIsNotReplacedByTheFallback() {
+    Latch latch =
+        new Latch() {
+          @Override
+          protected String apply(String target) {
+            return null;
+          }
+
+          @Override
+          protected String fallback(String target) {
+            return "fallback";
+          }
+        };
+
+    assertNull(latch.tryApplyOrNull("x"));
+    assertFalse(latch.isLatched());
+  }
+
   @Test
   void tryApplyOrDefaultReturnsTheResultWhenThereIsOne() {
     FieldLatch latch = new FieldLatch();

From 2b93e8ce3ce9b8e6536a880a68d24d976e8dd557 Mon Sep 17 00:00:00 2001
From: Douglas Q Hawkins 
Date: Fri, 2 Oct 2026 09:39:06 -0400
Subject: [PATCH 14/14] Rename Latch.tryApplyOrNull to tryApply

With an overridable fallback the result is no longer necessarily null
when the operation is skipped.

Co-Authored-By: Claude Opus 5.5 
---
 .../core/json/JsonParser216Helper.java        |  2 +-
 .../datadog/trace/util/LatchBenchmark.java    |  2 +-
 .../main/java/datadog/trace/util/Latch.java   | 10 +++---
 .../java/datadog/trace/util/LatchTest.java    | 34 +++++++++----------
 4 files changed, 24 insertions(+), 24 deletions(-)

diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java
index 1ba93ef15de..4c55aa0431e 100644
--- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java
+++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java
@@ -49,7 +49,7 @@ protected ByteQuadsCanonicalizer apply(UTF8StreamJsonParser jsonParser) {
           };
 
   public static boolean fetchInterner(UTF8StreamJsonParser jsonParser) {
-    ByteQuadsCanonicalizer symbols = SYMBOLS_LATCH.tryApplyOrNull(jsonParser);
+    ByteQuadsCanonicalizer symbols = SYMBOLS_LATCH.tryApply(jsonParser);
     // no symbol table to ask: assume interned (see the class comment)
     return symbols == null || ByteQuadsCanonicalizer216Helper.fetchInterner(symbols);
   }
diff --git a/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java
index c3bd113c5f7..cdda4ecd548 100644
--- a/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java
+++ b/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java
@@ -196,7 +196,7 @@ public void setup() throws Throwable {
       // the field really is missing
     }
     try {
-      LATCH_MISSING.tryApplyOrNull(missingTarget);
+      LATCH_MISSING.tryApply(missingTarget);
     } catch (NoSuchFieldError expected) {
       // the first failure is rethrown
     }
diff --git a/internal-api/src/main/java/datadog/trace/util/Latch.java b/internal-api/src/main/java/datadog/trace/util/Latch.java
index fac0695e1d9..9aea9ce4153 100644
--- a/internal-api/src/main/java/datadog/trace/util/Latch.java
+++ b/internal-api/src/main/java/datadog/trace/util/Latch.java
@@ -46,17 +46,17 @@ protected R fallback(T target) throws E {
    * result means nothing is available: neither the operation nor the fallback produced a value.
    */
   @Nullable
-  public final R tryApplyOrNull(T target) throws E {
+  public final R tryApply(T target) throws E {
     return latched ? fallback(target) : apply(target);
   }
 
   /**
-   * Like {@link #tryApplyOrNull}, but returns {@code defaultValue} when there is nothing available.
-   * It is also used when the operation or {@link #fallback} itself produced {@code null}, so a call
-   * and a skipped call always agree.
+   * Like {@link #tryApply}, but returns {@code defaultValue} when there is nothing available. It is
+   * also used when the operation or {@link #fallback} itself produced {@code null}, so a call and a
+   * skipped call always agree.
    */
   public final R tryApplyOrDefault(T target, R defaultValue) throws E {
-    final R result = tryApplyOrNull(target);
+    final R result = tryApply(target);
     return result != null ? result : defaultValue;
   }
 
diff --git a/internal-api/src/test/java/datadog/trace/util/LatchTest.java b/internal-api/src/test/java/datadog/trace/util/LatchTest.java
index 67e56646ef9..2b6d0c3da61 100644
--- a/internal-api/src/test/java/datadog/trace/util/LatchTest.java
+++ b/internal-api/src/test/java/datadog/trace/util/LatchTest.java
@@ -39,8 +39,8 @@ void performsTheOperationUntilLatched() {
     FieldLatch latch = new FieldLatch();
     latch.fieldPresent = true;
 
-    assertEquals(false, latch.tryApplyOrNull("x"));
-    assertEquals(false, latch.tryApplyOrNull("x"));
+    assertEquals(false, latch.tryApply("x"));
+    assertEquals(false, latch.tryApply("x"));
 
     assertEquals(2, latch.calls.get());
     assertFalse(latch.isLatched());
@@ -50,11 +50,11 @@ void performsTheOperationUntilLatched() {
   void rethrowsTheFirstFailureThenSkipsTheOperation() {
     FieldLatch latch = new FieldLatch();
 
-    assertThrows(NoSuchFieldError.class, () -> latch.tryApplyOrNull("x"));
+    assertThrows(NoSuchFieldError.class, () -> latch.tryApply("x"));
     assertTrue(latch.isLatched());
 
-    assertNull(latch.tryApplyOrNull("x"));
-    assertNull(latch.tryApplyOrNull("y"));
+    assertNull(latch.tryApply("x"));
+    assertNull(latch.tryApply("y"));
     assertEquals(1, latch.calls.get(), "later calls should be skipped");
   }
 
@@ -80,9 +80,9 @@ protected String fallback(String target) {
         };
 
     // the first failure is still rethrown, not replaced by the fallback
-    assertThrows(NoSuchFieldError.class, () -> latch.tryApplyOrNull("x"));
+    assertThrows(NoSuchFieldError.class, () -> latch.tryApply("x"));
 
-    assertEquals("fallback:x", latch.tryApplyOrNull("x"));
+    assertEquals("fallback:x", latch.tryApply("x"));
     assertEquals("fallback:y", latch.tryApplyOrDefault("y", "default"));
     assertEquals(1, calls.get(), "later calls should be skipped");
   }
@@ -102,7 +102,7 @@ protected String fallback(String target) {
           }
         };
 
-    assertNull(latch.tryApplyOrNull("x"));
+    assertNull(latch.tryApply("x"));
     assertFalse(latch.isLatched());
   }
 
@@ -161,14 +161,14 @@ void resume() {
   void unlatchResumesTheOperation() {
     Resumable latch = new Resumable();
 
-    assertEquals("called", latch.tryApplyOrNull("x"));
-    assertNull(latch.tryApplyOrNull("x"));
+    assertEquals("called", latch.tryApply("x"));
+    assertNull(latch.tryApply("x"));
     assertEquals(1, latch.calls);
 
     latch.resume();
 
     assertFalse(latch.isLatched());
-    assertEquals("called", latch.tryApplyOrNull("x"));
+    assertEquals("called", latch.tryApply("x"));
     assertEquals(2, latch.calls);
   }
 
@@ -182,7 +182,7 @@ protected String apply(String target) throws SQLException {
           }
         };
 
-    assertThrows(SQLException.class, () -> latch.tryApplyOrNull("x"));
+    assertThrows(SQLException.class, () -> latch.tryApply("x"));
     assertFalse(latch.isLatched());
   }
 
@@ -207,7 +207,7 @@ void handleNoSuchFieldReturnsTheResultWithoutLatching() {
     Handling latch = new Handling();
     latch.read = t -> "value";
 
-    assertEquals("value", latch.tryApplyOrNull("x"));
+    assertEquals("value", latch.tryApply("x"));
     assertFalse(latch.isLatched());
   }
 
@@ -220,11 +220,11 @@ void handleNoSuchFieldLatchesAndRethrowsTheFirstFailure() {
           throw failure;
         };
 
-    NoSuchFieldError thrown = assertThrows(NoSuchFieldError.class, () -> latch.tryApplyOrNull("x"));
+    NoSuchFieldError thrown = assertThrows(NoSuchFieldError.class, () -> latch.tryApply("x"));
 
     assertSame(failure, thrown);
     assertTrue(latch.isLatched());
-    assertNull(latch.tryApplyOrNull("x"));
+    assertNull(latch.tryApply("x"));
     assertEquals(1, latch.calls.get(), "later calls should be skipped");
   }
 
@@ -236,8 +236,8 @@ void handleNoSuchFieldDoesNotLatchOnOtherFailures() {
           throw new IllegalStateException("boom");
         };
 
-    assertThrows(IllegalStateException.class, () -> latch.tryApplyOrNull("x"));
-    assertThrows(IllegalStateException.class, () -> latch.tryApplyOrNull("x"));
+    assertThrows(IllegalStateException.class, () -> latch.tryApply("x"));
+    assertThrows(IllegalStateException.class, () -> latch.tryApply("x"));
 
     assertFalse(latch.isLatched());
     assertEquals(2, latch.calls.get());