Usage Examples

July 9, 2026 · View on GitHub

Fray is a tool that helps you to test multithreaded code. It is especially useful when you want to test the behavior of your code under different thread interleavings. Here is an example of how to add Fray to a Gradle project. You may find the complete source code here

Gradle Configuration

Add the following plugin to your build.gradle file:

plugins {
    id("org.pastalab.fray.gradle") version "0.8.5"
}

Class You Want to Test

Fray does not require any special annotations or modifications to your code. You can write your test code as usual. Here is an example of a simple class that you want to test:

import java.util.concurrent.atomic.AtomicInteger;

public class BankAccount {
    public AtomicInteger balance = new AtomicInteger(1000);
    public void withdraw(int amount) {
        // Check if there is enough balance
        if (balance.get() >= amount) {
            // Deduct the amount
            balance.set(balance.get() - amount);
        }
    }
}

Test Code

Original Test Code

Here is an example of a test code that tests the BankAccount class without Fray:

import org.junit.jupiter.api.Test;
public class BankAccountTest {
    public void myBankAccountTest() throws InterruptedException {
        BankAccount account = new BankAccount();
        Thread t1 = new Thread(() -> {
            account.withdraw(500);
            assert(account.balance.get() > 0);
        });
        Thread t2 = new Thread(() -> {
            account.withdraw(700);
            assert(account.balance.get() > 0);
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }

    @Test
    public void runTestUsingJunit() throws InterruptedException {
        myBankAccountTest();
    }
}

Test Code with Fray

Here is an example of a test code that tests the BankAccount class with Fray:

...
import org.junit.jupiter.api.extension.ExtendWith;
import org.pastalab.fray.junit.junit5.FrayTestExtension;
import org.pastalab.fray.junit.junit5.annotations.FrayTest;

@ExtendWith(FrayTestExtension.class)
public class BankAccountTest {
    public void myBankAccountTest() throws InterruptedException {
        ...
    }

    @FrayTest(
            iterations = 1000
    )
    public void runTestUsingFray() throws InterruptedException {
        myBankAccountTest();
    }
}
  • First you need to add the @ExtendWith(FrayTestExtension.class) annotation to the test class so that Fray can run the test.
  • Then you need to add the @FrayTest annotation to the test method. The iterations parameter specifies how many times the test method should be executed.
    • You may also specify scheduling algorithms and other parameters in the @FrayTest annotation. For more information, see the FrayTest.kt

Run the Test

Run test from command line

You can run the test from the command line using the following command:

./gradlew frayTest

Fray will launch all JUnit Platform tests tagged FrayTest (for example, via @FrayTest or @ConcurrencyTest) and run them multiple times.

If you want Fray to reuse a different Gradle Test task instead of the default local test task, configure the plugin extension:

fray {
    target = ":integration-test"
    testTask = "integrationTest"
}

target is optional and defaults to the current project. testTask defaults to test.

The generated frayTest task is only created when testTask = "test", and in that case the original test task is left unchanged. If you point Fray at another task such as integrationTest, Fray configures that task directly and does not create a separate frayTest task.

You can also further customize the generated frayTest task:

tasks.withType<FrayTestTask>()
    .configureEach {
  jvmArgs("-Xmx32G")
  jvmArgs("-Dfray.debug=true")
  testLogging {
    showStandardStreams = true
  }
}

Run test from IDE

If you are using an IDE, you can run the test as you would run any other JUnit test. Note that if you are using Intellij IDEA, you need to use Gradle test launcher instead of the JUnit test launcher to run the tests. Settings → Build, Execution, Deployment → Build Tools → Gradle → Run Tests Using → Gradle.

Reproduce a Failure

If a test fails, Fray will automatically generate a test case that reproduces the failure. Fray prints the path of the recording in the standard output.

Bug found in iteration test runTestUsingFray() repetition 0 of 1000, you may find detailed report and replay files in PATH_TO_FRAY_REPORT

In the report folder, Fray logs the detailed information of the test failure in fray.log file.

2025-02-17 13:43:40 [INFO]: Error found at iter: 1, step: 19, Elapsed time: 22ms
2025-02-17 13:43:40 [INFO]: Error: java.lang.AssertionError
Thread: Thread[#20037,Thread-20003,5,main]
java.lang.AssertionError
	at BankAccountTest.lambda$myBankAccountTest\$0(BankAccountTest.java:14)
	at java.base/java.lang.Thread.run(Thread.java:1583)

2025-02-17 13:43:40 [INFO]: The recording is saved to PATH_TO_FRAY_REPORT/recording

Fray provides two ways to reproduce a failure:


1. Replay using recorded random choices

You can rerun the program with the same scheduler and the recorded random choices used in the failing execution.
This technique is described in detail in our NSDI paper.

By default, Fray uses this method to reproduce failures.
Each report folder includes both the scheduler type and the random choices used during the run.
To replay the failure, simply provide the path to the replay file—no need to specify the scheduler explicitly:

@ConcurrencyTest(
        replay = "PATH_TO_FRAY_REPORT/recording"
)

2. Replay using the exact thread interleaving

Alternatively, you can reproduce the failure by replaying the exact thread schedule observed during the original execution. This requires that the schedule was recorded when the test was run.

To enable schedule recording, set the following system property: -Dfray.recordSchedule=true.

Note

This approach is less reliable if your program contains other sources of nondeterminism (e.g., random number generators, I/O operations, etc.) that influence the execution path.

Once the schedule is recorded, configure your test to use the ReplayScheduler:

@ConcurrencyTest(
    scheduler = ReplayScheduler.class,
    replay = "PATH_TO_FRAY_REPORT/recording"
)

Timeline Coverage

Fray can collect timeline coverage metrics during testing to measure how many distinct thread interleavings have been explored. This helps the scheduler prioritize unexplored interleavings and provides feedback on testing progress.

You can configure the coverage type via the annotation:

@ConcurrencyTest(
    collectTimelineCoverage = TimelineCoverageType.RESOURCE_ORDERING  // default
)

Or via the command line:

--timeline-coverage resource-ordering   # default
--timeline-coverage thread-ordering
--timeline-coverage none

Resource Ordering

Resource ordering coverage tracks which threads access each shared resource and in what order. For each resource (e.g., a lock, an atomic variable, a shared field), it records the directed transitions between threads. For example, "resource X is accessed by thread 1 first, followed by thread 2."

At the end of each iteration, it computes a fingerprint of the observed transition patterns across all resources that were accessed by at least 2 threads. Two executions produce the same fingerprint if and only if they exhibit the same set of thread-to-thread handoff patterns on shared resources.

This is the default because it is lightweight — it only records the resource identity and thread index per racing operation, with no stack trace resolution needed. The overhead is a few hash map lookups per shared memory access.

Thread Ordering

Thread ordering coverage tracks which racing operations each thread performs and their pairwise relationships within that thread. Each operation is identified by its type, class, and source location (via a stack trace hash). For each thread, it records all pairs of racing operations that the thread has executed.

At the end of each iteration, it combines the per-thread event sets and pair sets into a single fingerprint. Two executions produce the same fingerprint if and only if every thread performed the same set of racing operations at the same code locations, with the same pairwise occurrence relationships.

This mode is more expensive because it requires resolving a stack trace hash for every racing operation. This involves stack walking and filtering Fray-internal frames on each shared memory access. Use this mode when you need finer-grained coverage that distinguishes executions where the same resources are accessed in the same inter-thread order but from different code paths.

None

Disabling timeline coverage (TimelineCoverageType.NONE) removes all coverage tracking overhead. Use this when you only care about finding bugs and do not need coverage-guided scheduling feedback — for example, during replay or when using a fixed schedule.

Performance Summary

ModeOverheadWhen to use
RESOURCE_ORDERINGLow — hash map lookups per racing operationDefault; good balance of feedback and performance
THREAD_ORDERINGHigher — stack walking per racing operationWhen you need code-location-aware coverage
NONEZeroReplay, debugging, or maximum throughput

Note


Timeline coverage is only a rough indicator of testing progress. Comparing coverage numbers across different modes is not meaningful.

Feedback-Guided Scheduling (Fest)

The fest scheduler implements the feedback-guided adaptive scheduling algorithm from Feedback-guided Adaptive Testing of Distributed Systems Designs (NSDI '26). Instead of sampling schedules blindly, Fest uses timeline coverage as feedback to steer exploration toward unexplored behaviors — the concurrency-testing analogue of coverage-guided greybox fuzzing.

Fest wraps the existing scheduling algorithms and treats an existing randomized algorithm (random, pos, or pct) as a parametric generator whose schedule is fully determined by the pseudo-random choices it consumes.

Run it from the command line:

--scheduler fest                       # feedback-guided adaptive scheduling
--scheduler fest --fest-base pos       # base RSBT algorithm: random | pos (default) | pct
--scheduler fest --fest-mutation-count 5    # avg. recorded values mutated per replay
--scheduler fest --fest-mutation-budget 16  # mutations spent on each saved recording

Or via the annotation:

@ConcurrencyTest(scheduler = FestScheduler.class)

Fest reuses the configured timeline coverage (--timeline-coverage) as its feedback signal, so it works with both resource-ordering and thread-ordering. Because the feedback is just the generic coverage count, any future coverage metric drives Fest without changes to the algorithm.

NixOS

Fray downloads Corretto JDK 25 and runs ConcurrencyTest with it by default. However, NixOS cannot run dynamically linked executables. To run Fray on NixOS, you can provide environment variable JDK25_HOME and Fray will use provided JDK 25 instead of downloading it.

packages = with pkgs; [
  ...
  javaPackages.compiler.openjdk25
];
shellHook = ''
  export JDK25_HOME="${pkgs.javaPackages.compiler.openjdk25.home}"
''

Agent Mode

Fray also provides a Java agent that lets you run Fray with existing Java applications without using the Fray launcher. This is useful when your application is already running inside a deterministic environment (such as Antithesis) and you only want Fray to control thread interleavings.

To use the agent, you can start from Fray's prebuilt Docker image.

FROM ghcr.io/cmu-pasta/fray:0.8.5 as fray

COPY --from=fray /nix /nix
COPY --from=fray /opt/fray /opt/fray

After you have the image, run your application with the Fray agent:

  • Replace the java command with the instrumented /opt/fray/java-inst/bin/java.
  • If you use launchers such as Gradle or Maven, set JAVA_HOME to /opt/fray/java-inst.
  • Add the following two agents:
    • -javaagent:/opt/fray/libs/fray-core-FRAY_VERSION-SNAPSHOT-all.jar=FRAY_ARGS
      • FRAY_ARGS are the same arguments you would pass to the Fray launcher, separated by colons (:).
      • For example, to use the pos scheduler and enable memory interleaving:
        -javaagent:/opt/fray/libs/fray-core-0.8.5-SNAPSHOT-all.jar=-m:--scheduler:pos
        
    • -agentpath:/opt/fray/native-libs/libjvmti.so

Use Fray inside Antithesis

To fully leverage Antithesis's fuzzing capabilities, add the following argument to the Fray agent: --randomness-provider:antithesis-sdk-random. You can also set this system property to have Fray report errors through the Antithesis SDK: -Dfray.antithesisSdk=true.

Note


Ensure the Antithesis SDK is available on your application's classpath. Fray does not package the Antithesis SDK.