Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gemini/rules/async-test-runtime-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ When modifying these elements, audit for:

### se.deversity.asynctest.runner.ConcurrencyRunner.execute(org.junit.jupiter.api.extension.ReflectiveInvocationContext<java.lang.reflect.Method>,se.deversity.asynctest.AsyncTestConfig)
- **Invariant**: The timeoutAlreadyReported flag, and the per-step guarded cleanup in the finally block, are both deliberate. A pre-round deadline check throws an error that has already been through timeoutError(), and each cleanup step is wrapped in its own try so one failure cannot suppress the next.
- **Breaks if changed**: The flag is removed as redundant — the catch block then sends the same error through timeoutError() a second time, producing two onTimeout callbacks and two copies of every report for one timeout. Or the cleanup steps are merged into one try, at which point a failing AsyncTestContext.uninstall() skips the livelock snapshot and leaks context into the next test.
- **Breaks if changed**: The flag is removed as redundant — the catch block then sends the same error through timeoutError() a second time, producing two onTimeout callbacks and two copies of every report for one timeout. Or the cleanup steps are merged into one try, at which point a failing AsyncTestContext.uninstall() skips the latch.countDown() and the round waits out its timeout for nothing.

## PII / Privacy Guardrails

Expand Down
6 changes: 3 additions & 3 deletions .vibetags-mod-async-test-lib

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
- **148 detectors** — deadlocks, race conditions, virtual-thread pinning, lifecycle bugs, misused JDK types and more; a bare `@AsyncTest` runs the 12 in `Preset.ESSENTIALS` and `detectAll = true` runs them all. See [Detectors](#detectors) for what feeds them.
- **JUnit native, 5 and 6** — a plain `@TestTemplate`: no JVM flags, no required configuration, and it works from Kotlin, Groovy, Scala and Clojure. Jupiter 5.9.3 through 6.1.2, verified per release ([compatibility table](docs/BUILDING.md#junit-compatibility), [language notes](docs/JVM_LANGUAGES.md)).
- **Every finding says how far to trust it** — each detector carries a trust tier, so `failOn` can gate a merge on the measured end of the scale while everything else is still reported ([the tiers](docs/DETECTOR_CATALOG.md#trust-tiers), [Evidence](#evidence-what-has-been-measured-and-on-whose-code)).
- **Optional agent** — `async-test-agent` rewrites field accesses, collection and lock calls, shared JDK objects, coordination primitives, `Object.wait` and `notify`, `Thread.sleep`, `System.gc`, `Thread.start` and `Thread.setDaemon`, and the monitor of `synchronized (owner.field)`, with Byte Buddy, so **51 of them can see code you did not modify** instead of 4. Being able to see is not the same as firing: on 82 third-party subjects two of the 21 exposed at the time produced every finding, and the other nineteen were correctly silent because nothing in that corpus writes the idiom they model. Not needed for default use, and the core artifact does not depend on Byte Buddy ([docs/AGENT.md](docs/AGENT.md)).
- **Optional agent** — `async-test-agent` rewrites field accesses, collection and lock calls, shared JDK objects, coordination primitives, `Object.wait` and `notify`, `Thread.sleep`, `System.gc`, `Thread.start` and `Thread.setDaemon`, and the monitor of `synchronized (owner.field)`, with Byte Buddy, so **52 of them can see code you did not modify** instead of 4. Being able to see is not the same as firing: on 82 third-party subjects two of the 21 exposed at the time produced every finding, and the other nineteen were correctly silent because nothing in that corpus writes the idiom they model. Not needed for default use, and the core artifact does not depend on Byte Buddy ([docs/AGENT.md](docs/AGENT.md)).
- **CI-ready out of the box** — JUnit XML, machine-readable JSON, SARIF, or plain `AssertionError` fail-gates, straight into GitHub Actions, Jenkins and GitLab CI.

<div align="center">
Expand Down Expand Up @@ -97,8 +97,8 @@ only 21 of the 146 shipped when this was first measured could see anything at al
those produced every finding in the corpus. Saying so is the difference between a measurement and a marketing number. Two more
lanes exist for exactly that reason. One records what the body did, the way a user following
`AsyncTestContext` would; the other attaches the agent and writes the bug next to its fix. Between
them 135 of the 148 detectors are paired with a case that must fire and a twin that must stay
silent, and the other 13 carry a written reason. 34 of those pairs are what let their detectors
them 148 of the 148 detectors are paired with a case that must fire and a twin that must stay
silent, and a detector with no pair fails the build. 34 of those pairs are what let their detectors
carry `VERDICT`, the tier a build can fail on; a pair alone is not enough, because the detector
must also decide from something other than the test's own record call. HikariCP joins the corpus
as an eighth library
Expand All @@ -110,8 +110,8 @@ above, and the correct idioms the happens-before model does not see yet are list
with the reason, so that a fix flips them visibly.

The library agent pairs put the woven JDK call inside Guava, Jackson, HikariCP, Spring, commons-lang3
or Groovy instead of the test file, so 19 of the 47 agent-fed detectors are measured on a call site
nobody here compiled; `EXPLICIT_GC` is refused a pair in every lane, and `MISSED_SIGNAL`,
or Groovy instead of the test file, so 19 of the 48 agent-fed detectors are measured on a call site
nobody here compiled; `EXPLICIT_GC`, `COMPLETABLE_FUTURE_OBTRUDE_ABUSE`, `MISSED_SIGNAL`,
`ABA_PROBLEM`, `SYNCHRONIZED_NON_FINAL`, `CONCURRENT_MAP_CHECK_THEN_ACT`, `EXECUTOR_SHUTDOWN`,
`FUTURE_IGNORED`, the three `CompletableFuture` detectors, the two interrupt detectors and
`SYNCHRONIZED_COLLECTION_ITERATION`, `SYSTEM_PROPERTY_MUTATION` and `PROCESS_STREAM_STARVATION`
Expand Down Expand Up @@ -402,8 +402,8 @@ job, pass `-Dasync-test.detectAll=true` (`mvn test -Dasync-test.detectAll=true`;
on every `@AsyncTest`: `includes` still select exactly their list and `excludes` still apply.

**What feeds them.** Four read the JVM and the harness directly and need no configuration at
all. With the agent attached that becomes 51, because the woven streams carry what 47 more
detectors need without a line of instrumentation. The remaining 97 observe what the test body
all. With the agent attached that becomes 52, because the woven streams carry what 48 more
detectors need without a line of instrumentation. The remaining 96 observe what the test body
records explicitly through `AsyncTestContext`, and the catalog says which of three reasons
keeps each of them there. When a detector is
enabled but nothing can feed it, the runner says so once per JVM at INFO rather than letting an
Expand Down
2 changes: 1 addition & 1 deletion async-test-lib/.claude/rules/async-test-runtime-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ When modifying these elements, audit for:

### se.deversity.asynctest.runner.ConcurrencyRunner.execute(org.junit.jupiter.api.extension.ReflectiveInvocationContext<java.lang.reflect.Method>,se.deversity.asynctest.AsyncTestConfig)
- **Invariant**: The timeoutAlreadyReported flag, and the per-step guarded cleanup in the finally block, are both deliberate. A pre-round deadline check throws an error that has already been through timeoutError(), and each cleanup step is wrapped in its own try so one failure cannot suppress the next.
- **Breaks if changed**: The flag is removed as redundant — the catch block then sends the same error through timeoutError() a second time, producing two onTimeout callbacks and two copies of every report for one timeout. Or the cleanup steps are merged into one try, at which point a failing AsyncTestContext.uninstall() skips the livelock snapshot and leaks context into the next test.
- **Breaks if changed**: The flag is removed as redundant — the catch block then sends the same error through timeoutError() a second time, producing two onTimeout callbacks and two copies of every report for one timeout. Or the cleanup steps are merged into one try, at which point a failing AsyncTestContext.uninstall() skips the latch.countDown() and the round waits out its timeout for nothing.

## PII / Privacy Guardrails

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@

import org.jspecify.annotations.Nullable;

import se.deversity.asynctest.diagnostics.CompletableFutureObtrudeDetector;
import se.deversity.asynctest.diagnostics.ThreadPoolMonitor;
import se.deversity.asynctest.diagnostics.ABAProblemDetector;
import se.deversity.asynctest.diagnostics.BlockingQueueDetector;
Expand Down Expand Up @@ -704,6 +705,15 @@ public static boolean completeExceptionally(CompletableFuture<Object> receiver,
return completed;
}

/** Tells the run's obtrude detector: the call is the finding, whether or not it then throws. */
private static void noteObtrude(CompletableFuture<Object> receiver) {
CompletableFutureObtrudeDetector detector =
AsyncTestContext.currentCompletableFutureObtrudeDetector();
if (detector != null) {
detector.recordObtrude(receiver, "CompletableFuture", Thread.currentThread());
}
}

/**
* Weaves {@code CompletableFuture.obtrudeValue(Object)}, which completes the future whatever
* it held (#741).
Expand All @@ -714,6 +724,7 @@ public static boolean completeExceptionally(CompletableFuture<Object> receiver,
*/
public static void obtrudeValue(CompletableFuture<Object> receiver, @Nullable Object value) {
ControlledSchedule.point();
noteObtrude(receiver);
HappensBefore.release(receiver);
boolean obtruded = false;
try {
Expand All @@ -736,6 +747,7 @@ public static void obtrudeValue(CompletableFuture<Object> receiver, @Nullable Ob
*/
public static void obtrudeException(CompletableFuture<Object> receiver, Throwable failure) {
ControlledSchedule.point();
noteObtrude(receiver);
HappensBefore.release(receiver);
boolean obtruded = false;
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2411,6 +2411,12 @@ public static SharedCollectionDetector sharedCollectionDetector() {
return context == null ? null : context.explicitGcDetector;
}

/** {@return the {@link CompletableFutureObtrudeDetector} for the calling thread's test, or {@code null}} */
static @Nullable CompletableFutureObtrudeDetector currentCompletableFutureObtrudeDetector() {
AsyncTestContext context = CURRENT.get();
return context == null ? null : context.completableFutureObtrudeDetector;
}

/** {@return the {@link DaemonThreadHygieneDetector} for the calling thread's test, or {@code null}} */
static @Nullable DaemonThreadHygieneDetector currentDaemonThreadHygieneDetector() {
AsyncTestContext context = CURRENT.get();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
/**
* Detects use of deprecated and unsafe {@link Thread} API methods:
* {@code Thread.stop()}, {@code Thread.suspend()}, {@code Thread.resume()},
* {@code Thread.destroy()}, and {@code Thread.countStackFrames()}.
* {@code Thread.destroy()}, {@code Thread.countStackFrames()}, and {@code Thread.getId()}, which is
* deprecated in favour of {@code threadId()} rather than unsafe.
*
* <p>These methods were deprecated (and later removed or made no-ops in Java 20+) because:
* <ul>
Expand All @@ -33,6 +34,10 @@
* targetThread.stop(); // dangerous!
* }</pre>
*
* <p>Fed by that call and not by the agent: {@code stop}, {@code suspend} and {@code resume} throw
* from JDK 20 and are gone in JDK 26, so a woven call site would exist only in bytecode built for an
* older release.
*
* @since 0.10.0
*/
public class DeprecatedThreadApiDetector {
Expand All @@ -41,7 +46,7 @@ public class DeprecatedThreadApiDetector {
public static final Set<String> DEPRECATED_APIS = Collections.unmodifiableSet(
new LinkedHashSet<>(Arrays.asList(
"Thread.stop", "Thread.suspend", "Thread.resume",
"Thread.destroy", "Thread.countStackFrames")));
"Thread.destroy", "Thread.countStackFrames", "Thread.getId")));

private static class ApiUseEvent {
final String apiName;
Expand All @@ -60,11 +65,13 @@ private static class ApiUseEvent {
*
* @param apiName one of {@code "Thread.stop"}, {@code "Thread.suspend"},
* {@code "Thread.resume"}, {@code "Thread.destroy"},
* {@code "Thread.countStackFrames"} (null-safe)
* {@code "Thread.countStackFrames"}, {@code "Thread.getId"}; any other name, such as the
* {@code "Thread.interrupt"} a wrapper around every thread call might pass, is
* not recorded (null-safe)
* @param thread the calling thread (null-safe)
*/
public void recordApiUse(String apiName, Thread thread) {
if (apiName == null || thread == null) return;
if (apiName == null || thread == null || !DEPRECATED_APIS.contains(apiName)) return;
events.add(new ApiUseEvent(apiName, thread.getName()));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ public final class DetectorFeeds {
// Process.waitFor, and the getters that take a child's stdout or stderr pipe: a wait
// no other thread drains for is a hang once the child writes a pipe's worth.
DetectorType.PROCESS_STREAM_STARVATION,
// The woven obtrudeValue and obtrudeException, which the happens-before model already
// read: the call overwrites what a stage may already have seen, so it is the finding.
DetectorType.COMPLETABLE_FUTURE_OBTRUDE_ABUSE,
// Every call on a JPA EntityManager or Hibernate Session, matched by name, since
// neither may be on the agent's class path (#992).
DetectorType.JDBC_CONNECTION_SHARED,
Expand All @@ -177,8 +180,9 @@ public final class DetectorFeeds {
* Fed by the JVM and the harness with no recording call.
*
* <p>Four, each with the deciding source: {@code DeadlockDetector.analyze()} samples
* {@code ThreadMXBean.findDeadlockedThreads()} on its own; {@code ConcurrencyRunner} pushes a
* thread-dump snapshot into {@code LivelockDetector} for every worker of every round;
* {@code ThreadMXBean.findDeadlockedThreads()} on its own; {@code ConcurrencyRunner} tells
* {@code LivelockDetector} which workers are in their bodies, and asks it to classify them when a
* round times out;
* {@code StaticInitDeadlockDetector.analyze()} walks the live stacks for threads parked in
* {@code <clinit>} frames; and {@code ConcurrencyRunner} opens a {@link JfrPinningStream} that
* hands {@code VirtualThreadPinningDetector} the JVM's {@code jdk.VirtualThreadPinned} events
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ private static Classified row(DetectorType type, String detectorClass, String sp
row(DetectorType.SHARED_DEFLATER, "SharedDeflaterDetector", "SharedDeflater", TrustTier.VERDICT, Evidence.CONTEXTUAL),
row(DetectorType.THIS_ESCAPE, "ThisEscapeDetector", "ThisEscape", TrustTier.PROMPT, Evidence.ASSERTED),
row(DetectorType.THREAD_LOCAL_RANDOM_MISUSE, "ThreadLocalRandomMisuseDetector", "ThreadLocalRandomMisuse", TrustTier.PROMPT, Evidence.ASSERTED),
row(DetectorType.COMPLETABLE_FUTURE_OBTRUDE_ABUSE, "CompletableFutureObtrudeDetector", "CompletableFutureObtrude", TrustTier.PROMPT, Evidence.ASSERTED),
row(DetectorType.COMPLETABLE_FUTURE_OBTRUDE_ABUSE, "CompletableFutureObtrudeDetector", "CompletableFutureObtrude", TrustTier.PROMPT, Evidence.OBSERVED),
row(DetectorType.SPURIOUS_WAKEUP_HAZARD, "SpuriousWakeupDetector", "SpuriousWakeup", TrustTier.FACT, Evidence.ASSERTED),
row(DetectorType.LOCK_UPGRADE_DEADLOCK, "LockUpgradeDeadlockDetector", "LockUpgradeDeadlock", TrustTier.FACT, Evidence.ASSERTED),
row(DetectorType.TRY_LOCK_MISUSE, "TryLockMisuseDetector", "TryLockMisuse", TrustTier.VERDICT, Evidence.OBSERVED),
Expand Down
Loading
Loading