diff --git a/ddprof-lib/src/test/cpp/threadFilter_lifecycle_ut.cpp b/ddprof-lib/src/test/cpp/threadFilter_lifecycle_ut.cpp new file mode 100644 index 0000000000..a8e1cd3d5e --- /dev/null +++ b/ddprof-lib/src/test/cpp/threadFilter_lifecycle_ut.cpp @@ -0,0 +1,152 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + * + * Coverage for three previously-untested cells in the thread-lifecycle × + * profiler-state matrix, exercised at the native layer (no JVM required): + * + * Cell 1 – Disabling + teardown: ThreadFilter::unregisterThread races the + * clearActive() drain that stop() performs before disabling. + * Cell 2 – Enabling + startup: ThreadFilter::registerThread races a + * concurrent init() call that alternately enables/disables the filter. + * Cell 3 – Disabled + teardown: unregisterThread(-1) called when the filter + * was never initialised (idle profiler path). + * + * All three test cases are runnable under TSan and ASan. + */ +#include "gtest/gtest.h" + +#ifdef __linux__ + +#include "threadFilter.h" +#include "thread.h" +#include "../../main/cpp/gtest_crash_handler.h" + +#include +#include +#include + +static constexpr const char TEST_NAME[] = "ThreadFilterLifecycle"; +static constexpr int kWorkers = 16; +static constexpr int kIterations = 2000; + +// ── Cell 1: unregisterThread racing clearActive (simulated stop() drain) ────── + +TEST(ThreadFilterLifecycle, UnregisterRacesClearActive) { + installGtestCrashHandler(); + + ThreadFilter filter; + filter.init("*"); + ASSERT_TRUE(filter.enabled()); + + std::atomic run{true}; + + // Drain thread: simulates what stop() does — clearActive() empties the active + // slots without disabling the filter, to flush in-flight samples. + std::thread drainer([&]() { + while (run.load(std::memory_order_relaxed)) { + filter.clearActive(); + } + }); + + std::vector workers; + workers.reserve(kWorkers); + for (int i = 0; i < kWorkers; i++) { + workers.emplace_back([&]() { + for (int j = 0; j < kIterations; j++) { + ProfiledThread::initCurrentThread(); + ProfiledThread* self = ProfiledThread::current(); + if (!self) { + ProfiledThread::release(); + continue; + } + ThreadFilter::SlotID slot = filter.registerThread(); + if (slot >= 0) { + filter.add(self->tid(), slot); + filter.remove(slot); + filter.unregisterThread(slot); + self->setFilterSlotId(-1); + } + ProfiledThread::release(); + } + }); + } + for (auto& t : workers) t.join(); + run.store(false, std::memory_order_relaxed); + drainer.join(); + + restoreDefaultSignalHandlers(); + SUCCEED(); +} + +// ── Cell 2: registerThread racing concurrent init() enable/disable toggles ──── + +TEST(ThreadFilterLifecycle, RegisterRacesInit) { + installGtestCrashHandler(); + + ThreadFilter filter; + + std::atomic run{true}; + + // Init thread: alternates between enabling ("*") and disabling (nullptr) the + // filter; this is a synthetic enable/disable stress on _enabled, not a + // faithful replay of start()/stop() (production stop() never calls init()). + std::thread initer([&]() { + int toggle = 0; + while (run.load(std::memory_order_relaxed)) { + // Even: enable ("*"), odd: disable (nullptr / empty string) + filter.init(toggle++ % 2 == 0 ? "*" : nullptr); + } + }); + + std::vector workers; + workers.reserve(kWorkers); + for (int i = 0; i < kWorkers; i++) { + workers.emplace_back([&]() { + for (int j = 0; j < kIterations; j++) { + // Mirror the production onThreadStart guard: skip if filter disabled. + if (!filter.enabled()) continue; + + ProfiledThread::initCurrentThread(); + ProfiledThread* self = ProfiledThread::current(); + if (!self) { + ProfiledThread::release(); + continue; + } + ThreadFilter::SlotID slot = filter.registerThread(); + if (slot >= 0) { + // filter may have been disabled between the enabled() check and + // registerThread(); the slot is still valid and must be released. + filter.unregisterThread(slot); + self->setFilterSlotId(-1); + } + ProfiledThread::release(); + } + }); + } + for (auto& t : workers) t.join(); + run.store(false, std::memory_order_relaxed); + initer.join(); + + restoreDefaultSignalHandlers(); + SUCCEED(); +} + +// ── Cell 3: unregisterThread with sentinel slot when filter never init'd ─────── + +TEST(ThreadFilterLifecycle, UnregisterWithNoInitDoesNotCrash) { + // No crash handler — single-threaded, expected outcome is "no crash". + ThreadFilter filter; + + // enabled() must be false without init(). + EXPECT_FALSE(filter.enabled()); + + // unregisterThread(-1) is what onThreadEnd calls when filterSlotId() == -1 + // (thread was registered before profiler started, so slot was never assigned). + // Must not crash, assert, or invoke UB. + filter.unregisterThread(-1); + + SUCCEED(); +} + +#endif // __linux__ diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/lifecycle/IdleThreadLifecycleTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/lifecycle/IdleThreadLifecycleTest.java new file mode 100644 index 0000000000..43a96aa83e --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/lifecycle/IdleThreadLifecycleTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.lifecycle; + +import com.datadoghq.profiler.JavaProfiler; +import com.datadoghq.profiler.Platform; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Regression test for the "Disabled + teardown" coverage cell. + * + *

Verifies that thread lifecycle events (ThreadStart/ThreadEnd JVMTI callbacks, + * ProfiledThread::release) are safe when the profiler has never been started. + * The JVMTI callbacks registered in JNI_OnLoad delegate to onThreadStart / + * onThreadEnd unconditionally; those paths must tolerate a never-started profiler. + * + *

Does NOT call profiler.execute() — the profiler stays idle throughout. + */ +public class IdleThreadLifecycleTest { + + private static final int THREAD_COUNT = 200; + + @Timeout(30) + @Test + public void threadLifecycleWhileProfilerIdle() throws Exception { + Assumptions.assumeTrue(!Platform.isJ9(), "J9 has a different JVMTI implementation"); + Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11)); + + JavaProfiler profiler = JavaProfiler.getInstance(); + // Profiler must NOT be started — verify it is idle before we begin. + String statusBefore = profiler.getStatus(); + assertFalse(statusBefore.contains("Running : true"), + "Profiler must be idle at test start; status: " + statusBefore); + + ExecutorService pool = Executors.newFixedThreadPool(16); + CountDownLatch done = new CountDownLatch(THREAD_COUNT); + for (int i = 0; i < THREAD_COUNT; i++) { + pool.submit(done::countDown); + } + assertTrue(done.await(10, TimeUnit.SECONDS), + "Not all " + THREAD_COUNT + " thread tasks completed"); + pool.shutdown(); + pool.awaitTermination(5, TimeUnit.SECONDS); + + // Profiler must still be idle — thread lifecycle events must not trigger a start. + String statusAfter = profiler.getStatus(); + assertFalse(statusAfter.contains("Running : true"), + "Profiler must remain idle after thread churn; status: " + statusAfter); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/lifecycle/StartWithChurningThreadsTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/lifecycle/StartWithChurningThreadsTest.java new file mode 100644 index 0000000000..7e82a0125e --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/lifecycle/StartWithChurningThreadsTest.java @@ -0,0 +1,135 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.lifecycle; + +import com.datadoghq.profiler.JavaProfiler; +import com.datadoghq.profiler.Platform; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Regression test for the "Enabling + startup/teardown" coverage cell. + * + *

Races profiler.execute() against threads that are already generating JVMTI + * ThreadStart/ThreadEnd events. The hazard window: execute() installs signal + * handlers, initialises the thread filter, and registers JVMTI callbacks one by + * one; a thread starting or ending during this sequence may call onThreadStart / + * onThreadEnd on a half-initialised engine. + * + *

Does NOT extend AbstractProfilerTest — needs direct control over + * execute()/stop() timing; uses the same pattern as ShutdownTest. + */ +public class StartWithChurningThreadsTest { + + private static final int CHURN_THREADS = 64; + private static final int CYCLES = 500; + // filter= disables thread filtering so all threads receive SIGVTALRM, + // maximising the chance of racing execute() with active thread churn. + private static final String PROFILER_CMD = "start,wall=1ms,filter=,jfr,file="; + + @Timeout(120) + @Test + public void startRacesThreadStartEnd() throws Exception { + Assumptions.assumeTrue(!Platform.isJ9(), "J9 has a different JVMTI implementation"); + Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11), "wall-clock requires Java 11+"); + Assumptions.assumeTrue(!Platform.isZing(), "Zing forces cstack=no"); + + JavaProfiler profiler = JavaProfiler.getInstance(); + Path recordings = Files.createTempDirectory("lifecycle-cell2-"); + Queue errors = new LinkedBlockingQueue<>(); + + AtomicBoolean running = new AtomicBoolean(true); + // Latch: churn threads signal they are running before execute() is called, + // ensuring JVMTI events fire during profiler initialisation. + CountDownLatch churnRunning = new CountDownLatch(CHURN_THREADS); + ExecutorService churn = Executors.newFixedThreadPool(CHURN_THREADS); + List> churnFutures = new ArrayList<>(CHURN_THREADS); + + for (int i = 0; i < CHURN_THREADS; i++) { + churnFutures.add(churn.submit(() -> { + churnRunning.countDown(); + while (running.get()) { + new Thread(() -> {}).start(); + } + })); + } + + try { + // Wait for churn to be active BEFORE calling execute() + assertTrue(churnRunning.await(10, TimeUnit.SECONDS), "Churn threads did not start"); + for (int cycle = 0; cycle < CYCLES; cycle++) { + Path jfr = Files.createTempFile(recordings, "c2-" + cycle + "-", ".jfr"); + try { + // execute() races with active thread churn + profiler.execute(PROFILER_CMD + jfr.toAbsolutePath()); + Thread.sleep(cycle % 3); + profiler.stop(); + } catch (Throwable t) { + errors.add(t); + break; + } finally { + Files.deleteIfExists(jfr); + } + } + } finally { + running.set(false); + churn.shutdown(); + if (!churn.awaitTermination(5, TimeUnit.SECONDS)) { + churn.shutdownNow(); + } + try { + profiler.stop(); + } catch (Exception ignored) { + // already stopped + } + deleteDirectory(recordings); + for (Future f : churnFutures) { + try { + f.get(); + } catch (ExecutionException e) { + errors.add(e.getCause()); + } catch (CancellationException ignored) {} + } + } + + if (!errors.isEmpty()) { + AssertionError composite = new AssertionError( + errors.size() + " error(s) occurred during churn/profiler race"); + for (Throwable t : errors) { + composite.addSuppressed(t); + } + throw composite; + } + } + + private static void deleteDirectory(Path dir) { + try { + Files.walk(dir) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(p -> { try { Files.deleteIfExists(p); } catch (IOException ignored) {} }); + } catch (IOException ignored) {} + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/lifecycle/StopWithChurningThreadsTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/lifecycle/StopWithChurningThreadsTest.java new file mode 100644 index 0000000000..8ccf123215 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/lifecycle/StopWithChurningThreadsTest.java @@ -0,0 +1,141 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.lifecycle; + +import com.datadoghq.profiler.JavaProfiler; +import com.datadoghq.profiler.Platform; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Regression test for the "Disabling + teardown" coverage cell. + * + *

Races profiler.stop() against concurrent thread creation and destruction. + * The hazard window: a thread calling JVMTI ThreadEnd (or ProfiledThread::release) + * concurrently with stop() can hit the ThreadFilter or signal handler after they + * have been torn down. + * + *

Does NOT extend AbstractProfilerTest — needs direct control over + * execute()/stop() timing; uses the same pattern as ShutdownTest. + */ +public class StopWithChurningThreadsTest { + + private static final int CHURN_THREADS = 64; + private static final int CYCLES = 500; + // wall=1ms maximises signals in-flight during teardown; filter= disables + // thread filtering so all threads receive SIGVTALRM, increasing collision + // probability with ThreadEnd. + private static final String PROFILER_CMD = "start,wall=1ms,filter=,jfr,file="; + + @Timeout(120) + @Test + public void stopRacesThreadEnd() throws Exception { + Assumptions.assumeTrue(!Platform.isJ9(), "J9 has a different JVMTI implementation"); + Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11), "wall-clock requires Java 11+"); + Assumptions.assumeTrue(!Platform.isZing(), "Zing forces cstack=no"); + + JavaProfiler profiler = JavaProfiler.getInstance(); + Path recordings = Files.createTempDirectory("lifecycle-cell1-"); + Queue errors = new LinkedBlockingQueue<>(); + + AtomicBoolean running = new AtomicBoolean(true); + CountDownLatch churnRunning = new CountDownLatch(CHURN_THREADS); + ExecutorService churn = Executors.newFixedThreadPool(CHURN_THREADS); + List> churnFutures = new ArrayList<>(CHURN_THREADS); + + for (int i = 0; i < CHURN_THREADS; i++) { + churnFutures.add(churn.submit(() -> { + churnRunning.countDown(); + while (running.get()) { + // No join — thread dies on its own schedule, so ThreadEnd + // fires at an unpredictable time relative to stop(). + new Thread(() -> {}).start(); + } + })); + } + + try { + assertTrue(churnRunning.await(10, TimeUnit.SECONDS), "Churn threads did not start"); + for (int cycle = 0; cycle < CYCLES; cycle++) { + Path jfr = Files.createTempFile(recordings, "c1-" + cycle + "-", ".jfr"); + try { + profiler.execute(PROFILER_CMD + jfr.toAbsolutePath()); + // Minimal delay: hit stop() while signals are still in-flight + // and threads are actively dying. Varies per cycle to avoid + // always landing in the same phase of the signal period. + Thread.sleep(cycle % 3); + profiler.stop(); + } catch (Throwable t) { + if (!errors.offer(t)) { + fail("Failed to record test error", t); + } + break; + } finally { + Files.deleteIfExists(jfr); + } + } + } finally { + running.set(false); + churn.shutdown(); + if (!churn.awaitTermination(5, TimeUnit.SECONDS)) { + churn.shutdownNow(); + } + try { + profiler.stop(); + } catch (Exception ignored) { + // already stopped + } + deleteDirectory(recordings); + for (Future f : churnFutures) { + try { + f.get(); + } catch (ExecutionException e) { + if (!errors.offer(e.getCause())) { + fail("Failed to record churn worker error", e.getCause()); + } + } catch (CancellationException ignored) {} + } + } + + if (!errors.isEmpty()) { + AssertionError composite = new AssertionError( + errors.size() + " error(s) occurred during churn/profiler race"); + for (Throwable t : errors) { + composite.addSuppressed(t); + } + throw composite; + } + } + + private static void deleteDirectory(Path dir) { + try { + Files.walk(dir) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(p -> { try { Files.deleteIfExists(p); } catch (IOException ignored) {} }); + } catch (IOException ignored) {} + } +}