Skip to content

Commit e5ef83e

Browse files
jbachorikclaude
andcommitted
Fix Release-build flakiness in line-number-table death test; add jmethodID-churn stress test
The EXPECT_DEATH reproducer silently passed on -O3 builds because the optimizer eliminated the dead memcpy/free once owned_table's contents were never observed; a forced volatile read fixes that. Also adds a JUnit stress test that races class-unload churn against profiler dump/resolve and asserts native whitebox counters actually observed a stale jmethodID, not just that the JVM didn't crash. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 36f4468 commit e5ef83e

2 files changed

Lines changed: 312 additions & 0 deletions

File tree

ddprof-lib/src/test/cpp/lineNumberTableCopy_ut.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ TEST(LineNumberTableCopyRawTest, UnguardedCopyCrashesWhenSourceUnmapped) {
130130
(size_t)line_number_table_size * sizeof(jvmtiLineNumberEntry);
131131
void *owned_table = malloc(bytes);
132132
memcpy(owned_table, line_number_table, bytes); // <-- crashes here
133+
// Force the copied bytes to be observed before free(); otherwise an
134+
// optimizing (Release) build can prove owned_table's contents are
135+
// never read and eliminate the memcpy as dead code, silently
136+
// skipping the very fault this test exists to demonstrate.
137+
volatile unsigned char sink = *(volatile unsigned char *)owned_table;
138+
(void)sink;
133139
free(owned_table);
134140
},
135141
"");
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
/*
2+
* Copyright 2026, Datadog, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.datadoghq.profiler.memleak;
17+
18+
import org.junit.jupiter.api.Test;
19+
import org.junit.jupiter.api.Timeout;
20+
import org.objectweb.asm.ClassWriter;
21+
import org.objectweb.asm.Label;
22+
import org.objectweb.asm.MethodVisitor;
23+
import org.objectweb.asm.Opcodes;
24+
25+
import java.io.IOException;
26+
import java.lang.reflect.Method;
27+
import java.nio.file.Files;
28+
import java.nio.file.Path;
29+
import java.util.ArrayList;
30+
import java.util.List;
31+
import java.util.Map;
32+
import java.util.concurrent.atomic.AtomicBoolean;
33+
import java.util.concurrent.atomic.AtomicLong;
34+
35+
import static org.junit.jupiter.api.Assertions.assertTrue;
36+
37+
/**
38+
* Exploratory stress test for jmethodID invalidation, motivated by PROF-15385 (SIGSEGV in
39+
* {@code Lookup::fillJavaMethodInfo} copying a JVMTI line-number table for a stale jmethodID,
40+
* fixed by guarding that copy with {@code SafeAccess::isReadableRange}).
41+
*
42+
* <p>That fix addressed one call site. This test does not target a specific call site; it tries
43+
* to manufacture the same underlying condition — jmethodIDs whose declaring class is unloaded
44+
* while the profiler is actively resolving/dumping — at high enough concurrency and volume that
45+
* any *other*, currently-unguarded, use of a stale jmethodID has a chance to surface on its own.
46+
*
47+
* <p><b>Approach:</b>
48+
* <ul>
49+
* <li>Several driver threads continuously define throwaway classes in fresh, disposable
50+
* {@link ClassLoader}s, invoke their methods a few times (so the jmethodIDs are actually
51+
* captured by the profiler), then drop every reference.</li>
52+
* <li>A dedicated thread calls {@code System.gc()} on a tight loop for the duration of the
53+
* test, to encourage the JVM to actually unload those discarded classes/loaders while the
54+
* other threads are still running — rather than letting them merely accumulate.</li>
55+
* <li>The main thread calls {@code profiler.dump()} repeatedly throughout, so
56+
* {@code writeStackTraces}/{@code Lookup::resolveMethod}/{@code cleanupUnreferencedMethods}
57+
* run concurrently with class unloading, not just class-loading.</li>
58+
* <li>Both the {@code cpu} and {@code alloc} engines are active, since each has its own path
59+
* into {@code Lookup} and its own timing relative to unload.</li>
60+
* </ul>
61+
*
62+
* <p><b>Pass/fail signal:</b> a SIGSEGV/SIGABRT kills the whole JVM, so JUnit would never get to
63+
* report a failure directly -- reaching {@code profiler.stop()} at all is one necessary signal.
64+
* But that alone would pass vacuously if the churn never actually raced class unload against
65+
* {@code Lookup::resolveMethod}/{@code fillJavaMethodInfo}. To rule that out, this test reads the
66+
* native whitebox counters ({@code JavaProfiler#getDebugCounters()}) for {@code
67+
* jmethodid_skipped_count} and {@code line_number_table_unreadable} -- both are incremented in
68+
* {@code fillJavaMethodInfo} exactly when {@code SafeAccess::isReadableRange} rejects a stale
69+
* jmethodID's class/method metadata or line-number table (see flightRecorder.cpp) -- and asserts
70+
* at least one of them increased during the churn window. A pass therefore means both "no crash"
71+
* and "a stale jmethodID was actually observed and safely guarded." We cannot run this under ASan
72+
* here, since the profiler under test must run as a live JVMTI agent inside the same JVM process
73+
* the test is driving, not as a standalone ASan-instrumented binary the way the gtest-level native
74+
* unit tests can.
75+
*
76+
* <p><b>Limitations:</b> class unloading is JVM-discretionary and best-effort here — this test
77+
* does not wait for or verify that any particular class actually unloads (unlike
78+
* {@code CleanupAfterClassUnloadTest}/{@code WriteStackTracesAfterClassUnloadTest}, which target
79+
* one specific, already-diagnosed race and gate on a confirmed unload). It relies on volume and
80+
* concurrency to make unload-during-use likely across many call sites at once, but a clean run
81+
* is evidence of nothing more than "no crash was hit this time" for whatever code paths happened
82+
* to be exercised.
83+
*/
84+
public class JMethodIDInvalidationStressTest extends AbstractDynamicClassTest {
85+
86+
private static final int CHURN_THREADS = 4;
87+
private static final long DURATION_MILLIS = 5_000;
88+
private static final AtomicLong CLASS_COUNTER = new AtomicLong();
89+
private static final AtomicLong CHURN_ITERATIONS = new AtomicLong();
90+
91+
@Override
92+
protected String getProfilerCommand() {
93+
return "cpu=1ms,alloc=512k";
94+
}
95+
96+
@Test
97+
@Timeout(60)
98+
public void testProfilerSurvivesConcurrentClassUnloadDuringDump() throws Exception {
99+
stopProfiler();
100+
101+
Path baseFile = tempFile("jmethodid-churn-base");
102+
Path dumpFile = tempFile("jmethodid-churn-dump");
103+
104+
AtomicBoolean running = new AtomicBoolean(true);
105+
List<Thread> churnThreads = new ArrayList<>();
106+
Thread gcThread = null;
107+
108+
try {
109+
profiler.execute(
110+
"start," + getProfilerCommand() + ",jfr,mcleanup=true,file=" + baseFile.toAbsolutePath());
111+
Thread.sleep(200); // let the profiler stabilize
112+
113+
Map<String, Long> before = profiler.getDebugCounters();
114+
115+
for (int i = 0; i < CHURN_THREADS; i++) {
116+
Thread t = new Thread(() -> churnLoop(running), "jmethodid-churn-" + i);
117+
t.setDaemon(true);
118+
churnThreads.add(t);
119+
t.start();
120+
}
121+
122+
gcThread = new Thread(() -> {
123+
while (running.get()) {
124+
System.gc();
125+
try {
126+
Thread.sleep(20);
127+
} catch (InterruptedException e) {
128+
Thread.currentThread().interrupt();
129+
return;
130+
}
131+
}
132+
}, "jmethodid-churn-gc");
133+
gcThread.setDaemon(true);
134+
gcThread.start();
135+
136+
// Drive dumps from the main thread for the whole churn window so
137+
// writeStackTraces/resolveMethod/cleanupUnreferencedMethods run concurrently
138+
// with class unloading, not just after it.
139+
long deadline = System.currentTimeMillis() + DURATION_MILLIS;
140+
int dumps = 0;
141+
while (System.currentTimeMillis() < deadline) {
142+
profiler.dump(dumpFile);
143+
dumps++;
144+
Thread.sleep(50);
145+
}
146+
147+
running.set(false);
148+
for (Thread t : churnThreads) {
149+
t.join(5_000);
150+
}
151+
gcThread.join(5_000);
152+
153+
// Reaching this line means the profiler survived the whole churn window.
154+
Map<String, Long> after = profiler.getDebugCounters();
155+
profiler.stop();
156+
157+
assertTrue(Files.size(dumpFile) > 0,
158+
"Profiler produced no output after " + dumps + " dumps under jmethodID churn");
159+
160+
long churnIterations = CHURN_ITERATIONS.get();
161+
assertTrue(churnIterations > 0,
162+
"Churn threads never completed a single load/invoke/discard iteration -- the "
163+
+ "counter-delta assertion below would be meaningless without this, since it "
164+
+ "can't tell 'no stale jmethodID was hit' apart from 'churn never ran at all' "
165+
+ "(e.g. a regression in generateChurnClassBytecode or IsolatedClassLoader).");
166+
167+
long skippedDelta = after.getOrDefault("jmethodid_skipped_count", 0L)
168+
- before.getOrDefault("jmethodid_skipped_count", 0L);
169+
long unreadableLineTableDelta = after.getOrDefault("line_number_table_unreadable", 0L)
170+
- before.getOrDefault("line_number_table_unreadable", 0L);
171+
172+
assertTrue(skippedDelta > 0 || unreadableLineTableDelta > 0,
173+
"Churn window completed without the profiler ever encountering a stale/unmapped "
174+
+ "jmethodID (jmethodid_skipped_count delta=" + skippedDelta
175+
+ ", line_number_table_unreadable delta=" + unreadableLineTableDelta
176+
+ ") -- this test is meant to prove the guard actually fires under class-unload "
177+
+ "churn, not just that the JVM didn't crash; if this keeps failing, the churn "
178+
+ "isn't racing unload against resolveMethod/fillJavaMethodInfo tightly enough "
179+
+ "(consider more churn threads or a longer window).");
180+
} finally {
181+
running.set(false);
182+
for (Thread t : churnThreads) {
183+
try {
184+
t.join(2_000);
185+
} catch (InterruptedException ignored) {
186+
}
187+
}
188+
if (gcThread != null) {
189+
try {
190+
gcThread.join(2_000);
191+
} catch (InterruptedException ignored) {
192+
}
193+
}
194+
try {
195+
profiler.stop();
196+
} catch (Exception ignored) {
197+
}
198+
try {
199+
Files.deleteIfExists(baseFile);
200+
} catch (IOException ignored) {
201+
}
202+
try {
203+
Files.deleteIfExists(dumpFile);
204+
} catch (IOException ignored) {
205+
}
206+
}
207+
}
208+
209+
private void churnLoop(AtomicBoolean running) {
210+
while (running.get()) {
211+
try {
212+
String className =
213+
"com/datadoghq/profiler/generated/JMethodIDChurn" + CLASS_COUNTER.incrementAndGet();
214+
byte[] bytecode = generateChurnClassBytecode(className);
215+
216+
IsolatedClassLoader loader = new IsolatedClassLoader();
217+
Class<?> clazz = loader.defineClass(className.replace('/', '.'), bytecode);
218+
Object instance = clazz.getDeclaredConstructor().newInstance();
219+
Method compute = clazz.getDeclaredMethod("compute", int.class);
220+
Method allocate = clazz.getDeclaredMethod("allocate", int.class);
221+
222+
for (int i = 0; i < 20; i++) {
223+
compute.invoke(instance, i);
224+
allocate.invoke(instance, i);
225+
}
226+
// loader/clazz/instance/compute/allocate go out of scope here with no other
227+
// references -- eligible for unload as soon as the GC-pressure thread runs.
228+
CHURN_ITERATIONS.incrementAndGet();
229+
} catch (Throwable t) {
230+
// A reflective/loading failure here is not itself a signal; the failure mode
231+
// this test watches for is a JVM crash, not a Java-level exception. But we
232+
// still need churn to actually be happening for the counter-delta assertion
233+
// below to mean anything -- CHURN_ITERATIONS tracks that separately.
234+
}
235+
}
236+
}
237+
238+
/**
239+
* Generates a class with a {@code compute(int)} method (multi-line-number arithmetic, so
240+
* {@code GetLineNumberTable} on it returns more than one entry) and an {@code allocate(int)}
241+
* method (array allocation, so the alloc engine's stack capture includes this jmethodID too).
242+
*/
243+
private byte[] generateChurnClassBytecode(String internalName) {
244+
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
245+
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", null);
246+
247+
MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
248+
ctor.visitCode();
249+
ctor.visitVarInsn(Opcodes.ALOAD, 0);
250+
ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
251+
ctor.visitInsn(Opcodes.RETURN);
252+
ctor.visitMaxs(0, 0);
253+
ctor.visitEnd();
254+
255+
// public int compute(int x) {
256+
// int r = x; // line 10
257+
// r = r*1103515245 + 12345; // line 11
258+
// return r; // line 12
259+
// }
260+
MethodVisitor compute = cw.visitMethod(Opcodes.ACC_PUBLIC, "compute", "(I)I", null, null);
261+
compute.visitCode();
262+
Label l1 = new Label();
263+
compute.visitLabel(l1);
264+
compute.visitLineNumber(10, l1);
265+
compute.visitVarInsn(Opcodes.ILOAD, 1);
266+
compute.visitVarInsn(Opcodes.ISTORE, 2);
267+
Label l2 = new Label();
268+
compute.visitLabel(l2);
269+
compute.visitLineNumber(11, l2);
270+
compute.visitVarInsn(Opcodes.ILOAD, 2);
271+
compute.visitLdcInsn(1103515245);
272+
compute.visitInsn(Opcodes.IMUL);
273+
compute.visitLdcInsn(12345);
274+
compute.visitInsn(Opcodes.IADD);
275+
compute.visitVarInsn(Opcodes.ISTORE, 2);
276+
Label l3 = new Label();
277+
compute.visitLabel(l3);
278+
compute.visitLineNumber(12, l3);
279+
compute.visitVarInsn(Opcodes.ILOAD, 2);
280+
compute.visitInsn(Opcodes.IRETURN);
281+
compute.visitMaxs(0, 0);
282+
compute.visitEnd();
283+
284+
// public int[] allocate(int seed) {
285+
// int[] a = new int[16];
286+
// a[0] = seed;
287+
// return a;
288+
// }
289+
MethodVisitor allocate = cw.visitMethod(Opcodes.ACC_PUBLIC, "allocate", "(I)[I", null, null);
290+
allocate.visitCode();
291+
allocate.visitIntInsn(Opcodes.BIPUSH, 16);
292+
allocate.visitIntInsn(Opcodes.NEWARRAY, Opcodes.T_INT);
293+
allocate.visitVarInsn(Opcodes.ASTORE, 2);
294+
allocate.visitVarInsn(Opcodes.ALOAD, 2);
295+
allocate.visitInsn(Opcodes.ICONST_0);
296+
allocate.visitVarInsn(Opcodes.ILOAD, 1);
297+
allocate.visitInsn(Opcodes.IASTORE);
298+
allocate.visitVarInsn(Opcodes.ALOAD, 2);
299+
allocate.visitInsn(Opcodes.ARETURN);
300+
allocate.visitMaxs(0, 0);
301+
allocate.visitEnd();
302+
303+
cw.visitEnd();
304+
return cw.toByteArray();
305+
}
306+
}

0 commit comments

Comments
 (0)