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..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
@@ -1,11 +1,56 @@
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.
+ *
+ *
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:
+ *
+ *
+ *
Jackson interns field names by default, so "interned" is the likely answer.
+ *
Tainting an interned {@code String} taints the one shared instance, so every occurrence of
+ * that name (across requests) would look tainted. That is a false-positive risk.
+ *
The cost is a possible false negative: on such classpaths, an attacker-controlled JSON
+ * key reaching a sink is not detected. Values are still attributed to their 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. 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() {}
+ private static final Latch
+ SYMBOLS_LATCH =
+ new Latch() {
+ @Override
+ protected ByteQuadsCanonicalizer apply(UTF8StreamJsonParser jsonParser) {
+ return handleNoSuchField(jsonParser, parser -> parser._symbols);
+ }
+ };
+
public static boolean fetchInterner(UTF8StreamJsonParser jsonParser) {
- return ByteQuadsCanonicalizer216Helper.fetchInterner(jsonParser._symbols);
+ 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/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..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
@@ -1,9 +1,30 @@
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
+ * (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 {
private ByteQuadsCanonicalizer216Helper() {}
+ private static final Latch INTERNER_LATCH =
+ new Latch() {
+ @Override
+ protected Boolean apply(ByteQuadsCanonicalizer symbols) {
+ return handleNoSuchField(symbols, s -> s._interner != null);
+ }
+ };
+
public static boolean fetchInterner(ByteQuadsCanonicalizer symbols) {
- return symbols._interner != null;
+ return INTERNER_LATCH.tryApplyOrDefault(symbols, Boolean.TRUE);
}
}
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..3c526d3a2b6
--- /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,158 @@
+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";
+ private static final String UTF8_PARSER = "com.fasterxml.jackson.core.json.UTF8StreamJsonParser";
+
+ @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 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");
+ }
+
+ /**
+ * 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);
+ 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 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 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
+ 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(className)) {
+ bytes = renameField(bytes);
+ }
+ return defineClass(name, bytes, 0, bytes.length);
+ } catch (IOException e) {
+ throw new ClassNotFoundException(name, e);
+ }
+ }
+
+ private byte[] renameField(byte[] bytes) {
+ ClassWriter writer = new ClassWriter(0);
+ SimpleRemapper remapper =
+ new SimpleRemapper(className.replace('.', '/') + "." + field, field + "_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();
+ }
+ }
+}
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..cdda4ecd548
--- /dev/null
+++ b/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java
@@ -0,0 +1,377 @@
+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.
+ *
+ *
+ * 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}.
+ *
+ *
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.
+ *
+ *
+ *
+ * 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)
+@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