AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
기업용 테스트 프레임워크 async-test-lib, 동시성 버그를 재현·탐지하여 무작위 스케줄링에 의존하지 않음
@AsyncTest (async-test-lib) is an enterprise-grade test framework that makes concurrency bugs reproducible and detectable. Rather than hoping random thread scheduling will expose bugs, async-test forc
개발 도구Java · ShellGitHub @PIsberg직접 설치운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
@AsyncTest (async-test-lib) is an enterprise-grade test framework that makes concurrency bugs reproducible and detectable. Rather than hoping random thread scheduling will expose bugs, async-test forces them to happen using synchronized barriers and then diagnoses exactly what went wrong using specialized detectors.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
@AsyncTest - Asynchronous Testing Library for Java JUnit 5 & 6 concurrency stress testing — one annotation, 146 detectorsREADME 전문 보기 ↗
JUnit 5 & 6 concurrency stress testing — one annotation, 148 detectors
@AsyncTest runs your test body on N threads × M rounds, collided on a CyclicBarrier so every round starts at the same instant. No executor boilerplate, no CountDownLatch, no Thread.join loops.@AsyncTest runs the 12 in Preset.ESSENTIALS and detectAll = true runs them all. See Detectors for what feeds them.@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, language notes).failOn can gate a merge on the measured end of the scale while everything else is still reported (the tiers, Evidence).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 49 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 ().▶ Watch the walkthrough on YouTube
A concurrency detector is easy to make loud and hard to make right, so this project publishes two independent bodies of evidence and the denominator each was measured over. Neither is a claim about the JVM ecosystem. Both are reproducible from this repository.
corpus-eval/ runs @AsyncTest over 139 subjects from commons-lang3, commons-collections4, Guava,
Jackson, Caffeine, Netty, Spring, HikariCP and the JDK whose own javadoc states a thread-safety
contract. That sentence is the ground truth, quoted in the corpus table with the file and line
it came from, so a reader can check the classification without trusting this project. A finding on
a class documented as thread-safe is noise; a finding on one documented as not thread-safe is a
true positive. Nothing is inferred from how the code looks.
| Result | |
|---|---|
| Documented not thread-safe | 39 of 39 detected |
| Documented thread-safe, with any finding at all | 0 of 100 |
Documented thread-safe, with a VERDICT-tier HIGH or CRITICAL | 0 of 100 |
Identical on four platforms: JDK 21, 25 and 26 on Linux, and 26 on Windows. A hundred documented-safe subjects is what puts a 95% upper bound of 3.0% on the false-positive rate; the bound comes from the size of that denominator rather than from the run of zeroes, which is why the safe side is the larger half of the corpus on purpose. The last forty were chosen before they were first run, and none was dropped for what it drew.
The zero was not tuned. The last forty safe subjects first drew PROMPT-tier findings on 2 or 3
of them per run, and both causes became rules rather than exclusions: Caffeine's striped buffer
table written under a compare-and-swap spinlock the model could not see
(#554), and netty's chunks and pooled
buffers handed between threads through queues and atomic slots, each owner under its own lock or
none (#555). They join the four before
them: ConcurrentReferenceHashMap's hint
read re-established under its own lock, Netty's pool metadata built while the receiver is still
exclusive to its builder, Jackson's racy single-check cache recognised by how it converges, and
Guava's synchronized-method fields that compile to a flag and no monitor instruction were each
closed by a rule that names the idiom and ships a twin pair in both directions. Detection stayed at
the full unsafe group through every one of them, which is the number that matters while chasing
the noise column: a rule that quietens a false positive by weakening detection has not fixed
anything.
The eval prints its denominator before any rate, because a finding count on its own cannot
tell "no false positive from detector X" apart from "X never ran". In code that records nothing,
only 21 of the 146 shipped when this was first measured could see anything at all, and seven of
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
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
in the recording lane, because a connection pool is the one subject that cannot be exercised
without something to pool. The idioms lane writes correct user-code concurrency the way a test
body would, a queue hand-off, a volatile flag, a latch, a guarded wait loop, each beside its
broken twin and with every detector on: a correct idiom fails the run on any finding at FACT or
above, and the correct idioms the happens-before model does not see yet are listed as known gaps
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 45 agent-fed detectors are measured on a call site
nobody here compiled; EXPLICIT_GC is refused a pair in every lane, and 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
fed by the agent have a JDK pair only,
because no corpus library waits behind an if, pushes a popped node back, reassigns a field it
synchronizes on, checks a concurrent map before acting on it, leaves a pool it created running,
drops a failing task's future, blocks inside a callback or on the common pool, or waits for a
child process. Their
first run found a detector that dropped every sleep held under a ReentrantLock, which the
test-file pair could not see because it sleeps inside a synchronized method.
The method, the misses, the four platforms it was run on and what the numbers do not support are in the corpus eval.
The library's own CI proves the library builds. It does not prove the library can be upgraded
to, which is a different claim and the one that has actually failed here before. So every release
is swept through three unrelated projects that use @AsyncTest for their own reasons, each with
its own concurrency surface, its own build system and its own idea of what a hard test is:
| Project | What it is | Concurrency it puts under @AsyncTest | Build |
|---|---|---|---|
| BlindBean | Fully homomorphic encryption for Java, over Microsoft SEAL through Project Panama's FFM | 7 test classes, 29 methods: ciphertext lifecycle and close races, key rotation under load, the Paillier signed path, and the FHE native bridge, where a data race crosses into C++ and out of the JVM's reach | Maven |
| VibeTags | AI guardrails for Java: an annotation processor that generates agent-facing rule files from @AI* annotations | 5 test classes: the guardrail file writer, the module sidecar, the write cache and the logger, all of which are written concurrently by an annotation processor running inside javac | Maven and Gradle, both declared separately and bumped together |
| Skill3 | A fully local AI skill relearner | 1 test class, 8 methods: shared ObjectMapper, process-resource management, a parallel retrieval path and several stateless-under-concurrency claims | Gradle |
Why these three are worth more than a bigger number of synthetic cases:
@AsyncTest classes carry
@Tag("e2e"). A plain mvn test reports 957 green tests having run one of its five async
classes, which would sign off on a bump that never touched the other four. The sweep runs
-Pe2e and counts the async classes that actually ran, from the result XML rather than from the
console: Gradle prints BUILD SUCCESSFUL without listing a single test, so stdout cannot answer
the question at all.The sweep bumps each consumer to the published artifact, never to a working-tree build. That
rule is not pedantry: a poisoned ~/.m2 once made a sweep read the wrong bytes and produce a
confident, wrong account of which release removed a detector constant. The preflight now compares
sha1 against Maven Central before anything downstream is believed.
The rules, the failure each one prevents and the numbers from the latest sweep are in the regression sweep write-up; the procedure itself is the regression-test skill.
Latest run, 1.9.8 on 2026-08-25: 2793 tests across the three projects, zero failures, and no change to any consumer beyond the version string. Two of the three were four releases behind, so one sweep exercised eleven new detectors and the whole agent lockset overhaul at once.
Upgrading from 1.12? 1.13.0 removes the deprecated per-detector
@AsyncTestattributes and makes a bare@AsyncTestrunPreset.ESSENTIALSinstead of every detector. Read docs/migration/1.13.0.md before bumping.
Add the dependency to pom.xml:
<dependency>
<groupId>se.deversity.async-test-lib</groupId>
<artifactId>async-test-lib</artifactId>
<version>1.14.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>se.deversity.async-test-lib</groupId>
<artifactId>async-test-agent</artifactId>
<version>1.14.0</version>
<scope>test</scope>
</dependency>
The second artifact is the instrumentation agent. Start the test JVM with it, in
<build><plugins>: dependency:properties puts the agent jar's path in a property, and
surefire's argLine hands it to -javaagent:.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.11.0</version>
<executions>
<execution>
<goals><goal>properties</goal></goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-javaagent:${se.deversity.async-test-lib:async-test-agent:jar}=fields=true,collections=true,includes=com.example</argLine>
</configuration>
</plugin>
Replace com.example with your own package prefix: the agent weaves only classes whose
name starts with it. If JaCoCo or another plugin also sets argLine, start the line with
@{argLine} so both agents load. CI builds this exact snippet in
consumer-fixture-langs/quickstart and fails if it
stops attaching the agent or stops finding the example's race.
Write your first stress test:
package com.example;
import se.deversity.asynctest.AsyncTest;
import se.deversity.asynctest.FailOn;
class CounterTest {
private int counter = 0;
@AsyncTest(threads = 10, invocations = 100, detectAll = true,
failOn = FailOn.CRITICAL)
void counter_mustBeThreadSafe() {
counter++; // compound read-modify-write: not atomic
}
}
The package matters: the agent weaves only classes under step 1's includes= prefix, and
a test in the default package is woven by nothing and reports nothing. Keep the two in
step when you move the test into your own package.
failOn = FailOn.CRITICAL makes a finding fail the test. The default is
FailOn.NONE, which prints findings and lets the test pass — useful when adding
to an existing suite, surprising as a first experience.
Add the dependency to build.gradle.kts:
val asyncTestAgent = configurations.create("asyncTestAgent") {
isTransitive = false
}
dependencies {
testImplementation("se.deversity.async-test-lib:async-test-lib:1.14.0")
asyncTestAgent("se.deversity.async-test-lib:async-test-agent:1.14.0")
}
tasks.test {
useJUnitPlatform()
doFirst {
jvmArgs("-javaagent:${asyncTestAgent.singleFile}=fields=true,collections=true,includes=com.example")
}
}
The second artifact is the instrumentation agent, given its own configuration so the test
JVM can be started with it (-javaagent:). Replace com.example with your own package
prefix: the agent weaves only classes whose name starts with it. CI builds this exact
snippet in consumer-fixture-langs/quickstart.
Write your first stress test:
package com.example;
import se.deversity.asynctest.AsyncTest;
import se.deversity.asynctest.FailOn;
class CounterTest {
private int counter = 0;
@AsyncTest(threads = 10, invocations = 100, detectAll = true,
failOn = FailOn.CRITICAL)
void counter_mustBeThreadSafe() {
counter++; // compound read-modify-write: not atomic
}
}
The package must sit under step 1's includes= prefix: a test in the default package is
woven by nothing and reports nothing. failOn = FailOn.CRITICAL makes a finding fail the
test; the default FailOn.NONE prints findings and passes.
Run your tests:
./gradlew test -Dlicense.mock.mode=true
-Dlicense.mock.mode=true is what a local run without a licence key needs (CI
activates mock mode by itself). The -javaagent: from step 1 is what sees counter++,
a field read and write with no method call between them: without it this example
passes and reports nothing.
Deadlock detection needs no agent; it reads the JVM's own thread state.
async-test is a JUnit 5 @TestTemplate extension that stress-tests concurrent code by running the annotated method body simultaneously across N threads, repeated M times. A CyclicBarrier forces all threads to start each round at the same instant, maximising contention and surfacing bugs that only appear under real concurrency — not in sequential unit tests.
After the run, the detector registry analyses what was observed and reports any issues via the standard JUnit failure mechanism, so they surface in your IDE, CI dashboard, and test reports without any extra tooling.
@AsyncTest
└─► ConcurrencyRunner
├─ CyclicBarrier (all N threads collide on each invocation)
├─ Phase 1–N detectors observe the run
└─ DetectorRegistry.analyzeAll() → JUnit failure / listener events
148 detectors. A bare @AsyncTest runs the curated ESSENTIALS preset; one flag turns on every detector, or cherry-pick:
// The default: Preset.ESSENTIALS, 12 high-signal detectors
@AsyncTest
// Everything on
@AsyncTest(detectAll = true)
// Everything on except false sharing (too slow for this suite)
@AsyncTest(detectAll = true, excludes = { DetectorType.FALSE_SHARING })
// Explicit opt-in
@AsyncTest(includes = {DetectorType.DEADLOCKS, DetectorType.RACE_CONDITIONS})
To turn every detector on for a whole run without editing annotations, for an audit or a nightly
job, pass -Dasync-test.detectAll=true (mvn test -Dasync-test.detectAll=true; in Gradle,
systemProperty("async-test.detectAll", "true") on the test task). It reads as 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 49, because the woven streams carry what 45 more
detectors need without a line of instrumentation. The remaining 99 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
empty report read as a clean bill of health; docs/agent/diagnostics.md
lists those notices. Which detectors fall in which group is tabulated in
docs/DETECTOR_CATALOG.md, and how they behave on code nobody here
wrote is in the accuracy eval.
| Category | What it catches |
|---|---|
| Core | Deadlocks, livelocks, memory-model visibility (volatile gaps) |
| Race conditions | Unsynchronized field access, non-atomic compound ops (get+set on Atomic*) |
| Common JDK types | ArrayList, HashMap, StringBuilder, Calendar, SimpleDateFormat, DecimalFormat, Matcher, MessageDigest, TimeZone, Timer shared across threads |
| Virtual threads | Thread pinning (JDK-version-aware: synchronized pins only before JDK 24/JEP 491, class-init waits before JDK 26, native calls always), CPU-bound tasks, carrier exhaustion, ScopedValue misuse, context leak |
| Locks & monitors | Boxed-primitive lock (synchronized(Integer)), public lock exposure, lock leak, nested monitor lockout, StampedLock optimistic-read without validate(), lock downgrade |
| Lifecycle | Executor never shut down, thread leak, Future result ignored, CountDownLatch misuse, CyclicBarrier trip count wrong |
| Concurrency primitives | CompletableFuture chain issues, blocking on common pool, ForkJoinTask blocking, Exchanger, Phaser, Semaphore misuse |
| Hygiene | Interrupt swallowing, MDC context leak, System.setProperty from multiple threads, Process.waitFor before the child's output is drained, System.gc() in tests, deprecated thread API (Thread.stop() etc.) |
| Phase 13 | Daemon-thread hygiene, illegal notify*(), shared SecureRandom, shared WeakHashMap/IdentityHashMap, shared JDBC Connection/Statement/ResultSet |
| Phase 14 (new) |
Full parameter reference: docs/USAGE.md
JDK 25/26 detectors are wired into the pipeline (Phases 16 and 18). They are part of
detectAlland thePreset.ALL/STRICTbundles, each with aDetectorTypeconstant (STABLE_VALUE_MISUSE,STRUCTURED_TASK_SCOPE_MISUSE,GATHERER_CONCURRENCY_MISUSE,LAZY_CONSTANT_MISUSE,FINAL_FIELD_MUTATION,SHARED_KDF) thatincludesandexcludesname. Record events against them via the matchingAsyncTestContextaccessors (stableValueMisuseDetector()…lazyConstantMisuseDetector(),finalFieldMutationDetector(),sharedKdfDetector()); findings surface through the standard report andfailOngate.VirtualThreadPinningDetectoris JDK-version-aware since 1.7.0:synchronized/Object.waitevents are annotated as no-longer-pinning on JDK 24+ (JEP 491), class-init waits on JDK 26+. See docs/DETECTOR_CATALOG.md.
@AsyncTest(
threads = 10, // concurrent threads per invocation round
threadCounts = {2, 4, 8, 16, 32}, // OR: sweep multiple counts (one JUnit invocation per entry)
invocations = 100, // how many rounds to run
timeoutMs = 5000, // per-test timeout
useVirtualThreads = true, // Java 21+ virtual threads
virtualThreadStressMode = "HIGH", // OFF / LOW / MEDIUM / HIGH / EXTREME
preset = Preset.ESSENTIALS, // curated detector bundle (overrides detectAll)
detectAll = true, // legacy umbrella when preset = ALL
includes = { DetectorType.DEADLOCKS }, // OR: exactly these detectors, nothing else
excludes = { DetectorType.FALSE_SHARING }, // prune even from a preset/includes
failOn = FailOn.HIGH, // findings at/above this severity fail the test
replaySeed = 0L, // 0 = fresh per round; set on failure to replay it
controlledSchedule = false // true: the seed decides the interleaving too
)
| Parameter | Default | Description |
|---|---|---|
threads | 10 | Threads spawned per round |
threadCounts | {} | Schedule matrix — one invocation per entry; ignored when empty. Sweeps thread counts cheaply since race sensitivity is count-dependent |
invocations | 100 | Number of barrier rounds |
timeoutMs | 5000 | Whole-test timeout (ms) |
useVirtualThreads | true | Use Thread.ofVirtual() (Java 21+) |
preset | Preset.ESSENTIALS | Curated bundle: ESSENTIALS / ALL / STRICT / CI_FAST / NONE |
detectAll | false | Enable every detector, whatever preset says (includes still wins) |
includes | {} | Enable exactly these detectors — overrides preset/detectAll when non-empty |
excludes | {} | Detectors to skip — layers on top of any preset or includes and wins on conflict |
excludeIds | {} | Detectors to skip by id: a third-party detector's own id(), or a built-in's DetectorType name (1.13.0+) |
failOn | FailOn.NONE | Severity gate: findings at/above this level (LOW/MEDIUM/HIGH/CRITICAL) fail the test; NONE = report-only |
replaySeed | 0 | Per-round seed. 0 = fresh per round (printed on failure); set explicitly to replay the seed's random choices, and with controlledSchedule the interleaving too |
controlledSchedule | false | Run each round's workers one at a time, switching only at scheduling points (woven field accesses and substituted collection and atomic calls, ) in an order the replay seed decides, so a failing seed replays its interleaving (1.13.2+, experimental) |
@AsyncTest can also be placed on a class (shared config for all @TestTemplate
methods; method-level @AsyncTest wins) or on an annotation to compose reusable
presets like @EssentialsAsyncTest.
Gate CI on serious findings while adopting incrementally:
@AsyncTest(failOn = FailOn.HIGH) // HIGH and CRITICAL findings fail the test
void checkout_concurrently() { ... }
For a legacy codebase, record the current findings once and ratchet them down:
mvn test -Dasync-test.baseline=async-test-baseline.txt -Dasync-test.baseline.update=true # record
mvn test -Dasync-test.baseline=async-test-baseline.txt # enforce
Each baseline line is com.example.MyTest#method | DetectorName | finding — diff-friendly and
hand-editable; delete lines as you fix the findings. A finding the file does not name still fails,
even when the same detector has other findings in the file. Two-field lines written by releases
before 1.12.3 keep accepting the whole detector for that test.
On a passing run, PROMPT and ADVISORY reports print as one line each; add
-Dasync-test.report.full=true to print them in full. Listeners and report files always get the
full text.
class CounterTest {
private int counter = 0;
@AsyncTest(threads = 10, invocations = 100, detectAll = true)
void increment_mustBeAtomic() {
counter++; // BUG: compound read-modify-write, not atomic
}
}
// Fix: use AtomicInteger.incrementAndGet()
class LockTest {
private final Object lockA = new Object();
private final Object lockB = new Object();
@AsyncTest(threads = 4, invocations = 50, includes = DetectorType.DEADLOCKS)
void acquireLocks() {
if (Thread.currentThread().getId() % 2 == 0) {
synchronized (lockA) { synchronized (lockB) { /* work */ } }
} else {
synchronized (lockB) { synchronized (lockA) { /* work */ } }
// ^^^ opposite order — deadlock waiting to happen
}
}
}
class VirtualThreadTest {
private final List<String> items = Collections.synchronizedList(new ArrayList<>());
@AsyncTest(
threads = 100_000,
invocations = 5,
useVirtualThreads = true,
virtualThreadStressMode = "EXTREME",
detectAll = true
)
void highConcurrency() {
items.add("item-" + Thread.currentThread().threadId());
}
}
class HashMapCacheTest {
private final Map<String, String> cache = new HashMap<>(); // BUG: not thread-safe
@AsyncTest(threadCounts = {2, 4, 8, 16, 32, 64}) // 6 separate JUnit invocations
void put_thenGet() {
cache.put(UUID.randomUUID().toString(), "v");
}
}
// JUnit emits one test per count; race condition surfaces reliably at 16+
CompletionStage inside @AsyncTestclass AsyncPipelineTest {
@AsyncTest(threads = 8)
void hammer_pipeline() {
CompletableFuture<String> stage = service.processAsync(payload);
String result = AsyncAssert.awaitAsync(stage, Duration.ofSeconds(5));
assertEquals("ok", result);
}
}
// awaitAsync unwraps ExecutionException — failures surface as the real exception type
class LazyInitTest {
private static final RunOutcomes OUTCOMES = new RunOutcomes();
@AsyncTest(threads = 8, invocations = 100)
void initialise() {
Config config = Config.load(); // each worker prepares its own input
AsyncTestContext.rendezvous(); // all 8 meet here before anyone initialises
if (service.initialiseIfNeeded(config)) OUTCOMES.record("initialised");
}
@AfterAll
static void ranOnce() {
OUTCOMES.assertExactlyOnce("initialised"); // names the threads if it ran twice
}
}
class FlakyTest {
@AsyncTest // 1st run: failure prints replaySeed=4242L
@AsyncTest(replaySeed = 4242L) // re-run with the printed seed
void randomised_workload() {
var rng = new Random(AsyncTestContext.replaySeed());
Thread.sleep(rng.nextInt(10)); // randomised jitter is now deterministic
service.handle(payload(rng));
}
}
The seed alone replays the body's random choices, not the thread schedule: the OS still decides
which worker runs when. controlledSchedule = true hands that decision to the seed too. Workers run
one at a time and change hands only at scheduling points: every field access the agent weaves with
fields=true, every collection, concurrency-utility and atomic call it substitutes, and every
AsyncTestContext.schedulePoint(), so the seed a failing round prints
replays its interleaving:
class LostUpdateTest {
private int count;
@AsyncTest(threads = 2, invocations = 200, controlledSchedule = true) // each round tries a schedule
// @AsyncTest(threads = 2, controlledSchedule = true, replaySeed = 4242L) // the failing one, every time
void increment() {
int read = count;
AsyncTestContext.schedulePoint(); // not needed with the agent: the read and write are points
count = read + 1;
}
}
Workers run one at a time, so a controlled round explores interleavings, not memory-model
reorderings. A worker that blocks without reaching a point (a lock its paused peer holds, a sleep,
a get) leaves the schedule until its next point; the failure line says when that happened, since
only a round without such an escape is guaranteed to replay exactly.
@Test
void capture_findings_for_one_test() {
try (var ignored = AsyncTestListenerRegistry.registerScoped(myListener)) {
// myListener fires only inside this block
runMyAsyncTest();
}
// automatic unregister on close
}
More examples with runnable code: examples/
Release notes: docs/CHANGELOG.md
Register a listener in @BeforeAll to get structured output alongside the standard JUnit failure:
@BeforeAll
static void setup() {
// JUnit XML → GitHub Actions / Jenkins / GitLab CI test dashboards
AsyncTestListenerRegistry.register(new JUnitXmlReportListener());
// Structured JSON → dashboards, quality gates, custom tooling
AsyncTestListenerRegistry.register(new JsonReportListener());
// Throw AssertionError immediately on any finding
AsyncTestListenerRegistry.register(new StrictModeListener());
}
- name: Run tests
run: mvn test
- name: Upload async-test reports
uses: actions/upload-artifact@v4
if: always()
with:
name: async-test-reports
path: target/async-test-reports/
Findings appear as named test-case failures in the Actions UI — not just as stderr noise.
Flaky-test policy: the build does not configure Surefire's rerunFailingTestsCount — an intermittently failing @AsyncTest is a detector finding a real concurrency bug, not infrastructure noise, so we don't auto-rerun it away.
Scaling timeouts on slow/shared runners: every @AsyncTest(timeoutMs=...) budget can be scaled globally with -Dasync-test.timeout.multiplier=<factor> or the ASYNC_TEST_TIMEOUT_MULTIPLIER environment variable (precedence: system property, then env var, then 1.0). Use this instead of bumping individual annotations when detector setup overhead eats into a short timeout on a slow or oversubscribed runner (e.g. a 3-core macOS or Windows CI box) — an invalid or non-positive value falls back to 1.0. Prefer the env var in CI: it propagates automatically into Surefire's forked test JVMs, whereas a -D passed to the outer mvn process does not.
Full CI/CD setup guide: docs/CI_INTEGRATION.md
A companion IntelliJ IDEA plugin reads the JSON report and surfaces findings in a dedicated tool window — with severity colouring, full report text, and a Refresh action to pick up new results without leaving the IDE.
View → Tool Windows → async-test Findings
Setup:
cd intellij-plugin && ./gradlew buildPluginSee intellij-plugin/README.md for full instructions.
async-test-lib/ The library: @AsyncTest, the runner, and every detector.
This is the artifact consumers depend on.
async-test-agent/ Opt-in Java agent (Byte Buddy) that weaves field and
collection accesses so detectors see unmodified code.
async-test-analysis/ Opt-in ASM pre-scanner for virtual-thread pinning sites.
Depends on nothing else here.
consumer-fixture/ A consumer-shaped project proving every detector is
reachable through the published API.
consumer-fixture-langs/ The same proof from Kotlin, Groovy, Scala and Clojure.
corpus-eval/ Runs the detectors against real third-party libraries
and gates the false-positive and detection rates.
load-tests/ JMH throughput and memory benchmarks, with the measured
results pinned per release under results/.
evals/ Evals that measure whether coding agents actually obey
this repository's instruction files.
examples/ Self-contained example projects, each demonstrating a
concurrency defect against the release its pom pins.
intellij-plugin/ IDE tool window that renders the JSON report.
async-test-gradle-plugin/
async-test-maven-plugin/ Build plugins that attach the agent at startup in one block,
published with each release from 1.14.0 on (#1099).
tools/ Release and licensing utilities, and the demo.
docs/ All documentation, routed from docs/INDEX.md.
Only the first three are Maven reactor modules; they and the Maven plugin are what publishes to Maven Central, and the Gradle plugin goes to the Gradle Plugin Portal. The fixture, corpus, load-test and example projects consume the library the way a user would, which is what makes their results evidence; the rest is documentation and tooling around them.
| Resource | Description |
|---|---|
| API reference | Javadoc for every public type, one directory per release. All versions |
| docs/USAGE.md | Full @AsyncTest parameter reference, all detectors, examples |
| docs/CI_INTEGRATION.md | GitHub Actions, Jenkins, GitLab CI setup |
| docs/ARCHITECTURE.md | Execution flow, detector phases, extension points |
| docs/CHANGELOG.md | Version history |
| examples/ | 30+ runnable example projects |
| intellij-plugin/README.md | IntelliJ plugin setup |
| docs/INDEX.md | Documentation index — every document mapped to what it is for |
| docs/QUALITY_GATES.md | What must stay green: static analysis, coverage, mutation testing, japicmp |
Books that cover @AsyncTest and the problem it exists to solve.
Vibe Architecture: Designing, Scaling, and Guardrailing Large-Scale Systems in the Age of AI Orchestration by Peter Isberg.
Chapter 17, "The Asynchronous Trap", is about the class of bug AI-generated concurrent code produces and sequential unit tests never see. It uses this library as the worked example: forced-collision testing on a CyclicBarrier, what the detectors turn a bare timeout into, and how to feed that named diagnosis back to the assistant that wrote the bug.
PolyForm Noncommercial License 1.0.0 — free for non-commercial use.
Commercial use requires an annual license. One key covers your whole team — there are no per-seat keys. Prices are per year, excluding VAT or sales tax:
| Developers | Price (EUR/year) |
|---|---|
| 1–9 | €250 |
| 10–49 | €900 |
| 50–199 | €2,500 |
| 200+ | €6,000 |
| OEM / redistribution | from €10,000, negotiated |
Buy a license at deversity.se/pricing.html →
Checkout is handled by Paddle, our merchant of record, which shows prices in your local currency and handles VAT and sales tax. OEM and redistribution are negotiated — email us.
Your license key is sent by email after purchase; it is issued by hand, so allow one business day.
The license is bound to one email address that you nominate — set it once in your shared build
config as -Dlicense.user.email and every developer and CI job uses that same value. Operator
runbook and what to send customers: docs/LICENSING.md.
| Environment | Behavior |
|---|---|
CI (any GITHUB_ACTIONS or CI env var set, no key) | Auto-mocked — tests run freely |
Local, no key, -Dlicense.mock.mode=true | Mock mode active — tests run freely |
| Local, no key, no mock flag | The gate runs and can refuse. See below |
Real key via -Dlicense.key=<key> | Full validation against the licensing backend |
Offline file via -Dlicense.file=<path> | Signature-verified locally, no network attempted (air-gapped CI) |
First run on a new machine. With no key configured and no mock flag, the gate consults the licensing backend and a denial throws, before any test body runs:
java.lang.SecurityException: LICENSE DENIED: <reason> To run locally without a key: -Dlicense.mock.mode=true In CI (GITHUB_ACTIONS or CI env var set, no key): mock mode activates automatically.This is the gate working as intended, not a bug in your test. CI is unaffected: mock mode turns itself on there when no key is present, which is why a suite that passes in CI can still stop on a developer laptop.
To run locally without a key during development:
mvn test -Dlicense.mock.mode=true
Or add to your IDE's JVM args: -Dlicense.mock.mode=true. Setting it once in your IDE's default
JUnit configuration is the usual fix, so it applies to every run rather than being remembered
per-test.
Every run that proceeds without a validated commercial key (mock mode, CI auto-mock, or a free-mail address) prints a three-line licence notice to stderr, once per JVM, naming the licence, the pricing page and the contact address. It is not a warning and it does not fail anything; it goes away when a key is validated. It cannot be switched off by a flag, because it is the licence terms restated on the one path where they are easiest to forget.
Set your email identity when using a real key: [email protected]
Licensed builds do not depend on the licensing provider being up or reachable:
LICENSE: validator unavailable warning per JVM instead of failing. A
validator that answers and rejects the key still fails the build. Restore unconditional
fail-closed with -Dlicense.network.mode=strict.license.cache.ttl.hours (default 24), so a
fork-per-class suite makes one licensing call per day rather than one per test class.-Dlicense.file at it: the file is
Ed25519-signed and verified inside the JVM, with no network and no provider account. An invalid
file fails closed; it never falls back to online validation.-Dhttps.proxyHost=<host> -Dhttps.proxyPort=<port> flags apply. Set them on the forked test
JVM (Surefire argLine or systemPropertyVariables), not only on the Maven process.Exact failure semantics, the operator runbook and the offline-file format: docs/LICENSING.md.
AssertionError fail-gates, straight into GitHub Actions, Jenkins and GitLab CI.@AsyncTestRun your tests:
mvn test -Dlicense.mock.mode=true
-Dlicense.mock.mode=true: without a licence key the run stops with
LICENSE DENIED. CI sets mock mode automatically (CI or GITHUB_ACTIONS in the
environment); a local run needs the flag. See License.counter++. It compiles to a field read and a field write
with no method call between them, so only instrumented bytecode shows it. Without
the agent this example passes and reports nothing. Woven code runs slower: a body
making 200,000 synchronized calls took 3.4 s instead of 1.1 s.async-test-agent on the test classpath but not on
the command line, the runner loads it into the running JVM before the first round
instead, and logs runner.agent.attached source=classpath. That works on JDK 21 to 26,
but the JVM prints a four-line WARNING about it, and JEP 451 says a future JDK will
refuse it; the runner then logs runner.agent.attach.failed and the example reports
nothing again. Treat it as a fallback, not a setup. -Dasynctest.agent=off turns it
off (attaching the agent).Detection that needs no agent: deadlocks, which are read from the JVM's own thread state. If you want to see the library find something with zero setup, make two threads take two locks in opposite orders.
Shared stateful crypto (Cipher/Mac/Signature), non-atomic ConcurrentMap check-then-act, shared Deflater/Inflater, constructor this-escape, cached ThreadLocalRandom used off-thread |
| Phase 16 — JDK 25/26 preview | StableValue misuse (read-before-set / double-set / reentrant orElseSet), StructuredTaskScope lifecycle (fork-after-join, result-before-join, owner-confinement, missing join, and JDK 26 join-timeout hazards), parallel-Gatherer without a combiner |
| Phase 17 — shared stateful JDK objects | Shared ByteBuffer, CharsetEncoder/Decoder, Checksum, Deflater, iterators, FileChannel implicit-position races, high-contention Atomic* advisories, JSON-mapper reconfiguration after concurrent use |
| Phase 18 — JDK 25/26 GA (new) | LazyConstant misuse (JDK 26 Lazy Constants: reentrant / null-producing / repeat-running suppliers), reflective final-field mutation (JEP 500 — warned on JDK 26, denied later, JMM violation today), shared javax.crypto.KDF (JEP 510 — documented not thread-safe) |
AsyncTestContext.schedulePoint()