Engineering Guide

Find Swift Data Races with Thread Sanitizer in Cloud Mac CI

Find Swift Data Races with Thread Sanitizer in Cloud Mac CI

When the same XCTest suite passes consistently on a local machine but occasionally crashes in a parallel pipeline during a dictionary write, array subscript, or state assertion, the cause is usually not an “unstable machine.” More often, two execution paths are accessing shared mutable state at the same time. Data races depend on the exact scheduling order, so a single rerun can easily make the evidence disappear. A more effective approach is to create a dedicated Thread Sanitizer test job on a cloud Mac, amplify the race conditions, retain the result bundle, and trace state ownership backward from the first pair of conflicting accesses.

Separate the Sanitizer job from regular tests

Thread Sanitizer records memory accesses and tracks relationships between threads, increasing both runtime and memory usage. Do not enable it across the entire pipeline. Instead, create a ConcurrencySanitizer Scheme or Test Plan that includes only tests likely to modify state across threads, such as caches, download queues, database wrappers, callback bridges, and parallel parsers.

Split execution into three tiers:

Tier Test scope When to run
Fast checks Pure functions and standard unit tests Every commit
Race checks High-risk concurrency tests Every merge request
Extended checks Full unit and integration test suites Separate scheduled job

The Scheme must be shared in the repository, or the command-line environment will not be able to find it. Before committing, verify its name with xcodebuild -list -workspace App.xcworkspace. The test target should also disable the implicit randomness introduced by parallel test execution, then create concurrency explicitly inside each test. This ensures that the pressure comes from readable test code rather than an uncontrollable test scheduler.

Pin down a reproducible command-line run

First confirm the simulator names installed on the cloud Mac, then replace the device in the example with an available one. Assign separate DerivedData to every job so that two executors do not overwrite the same index and intermediate build artifacts.

set -euo pipefail

RUN_ID="${CI_RUN_ID:-local}"
RESULT_DIR="$PWD/Artifacts/tsan"
DERIVED_DATA="$PWD/.derived-data/tsan-$RUN_ID"

mkdir -p "$RESULT_DIR"

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme ConcurrencySanitizer \
  -configuration Debug \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -derivedDataPath "$DERIVED_DATA" \
  -enableThreadSanitizer YES \
  -resultBundlePath "$RESULT_DIR/result.xcresult"

Do not enable Address Sanitizer at the same time. Combining both forms of instrumentation increases resource costs and log noise, while making the source of a failure harder to identify. Release configurations also commonly include optimizations and different assertion behavior, so use Debug for the initial investigation. After confirming the fix, add validation with other configurations as required by the project.

A single green result only means that the race was not triggered by that particular schedule. It does not prove that the shared state is safe.

Deliberately increase task interleaving

The most valuable stress test does not blindly loop through the entire App. It performs reads, writes, cancellations, and resets concurrently around one shared object. The following test makes multiple tasks compete to update a counter and is useful for confirming that the detection pipeline is actually working.

final class UnsafeCounter: @unchecked Sendable {
    private(set) var value = 0

    func increment() {
        value += 1
    }
}

func testConcurrentIncrement() async {
    let counter = UnsafeCounter()

    await withTaskGroup(of: Void.self) { group in
        for _ in 0..<100 {
            group.addTask {
                counter.increment()
            }
        }
    }

    XCTAssertEqual(counter.value, 100)
}

@unchecked Sendable is not a fix. It only transfers responsibility to the developer. If production code relies on it, every usage site should be included in the review. To increase the detection rate, high-risk tests can be repeated 20 to 50 times, but the pipeline should have an overall timeout. Avoid adding long random sleeps; brief calls to Task.yield() are better suited to increasing interleaving while keeping execution controlled.

Repair state ownership

For application state, prefer making a single Actor the sole writer:

actor Counter {
    private var value = 0

    func increment() {
        value += 1
    }

    func currentValue() -> Int {
        value
    }
}

Callers must use await to access it, bringing the ownership boundary into the type system. If an existing interface must remain synchronous, use one clearly defined serial queue or a narrowly scoped lock. Do not lock some paths while allowing others to read directly. A lock protects an invariant, not merely one assignment statement.

Read the report from the first conflicting accesses

Thread Sanitizer reports are often long. Ignore subsequent cascading errors at first and focus on the first Read and Write pair, or the first two Write operations. Record the thread, queue, source line, and object creation site for each access, then answer three questions:

  1. Do both paths access the same instance?
  2. Who is expected to own that state?
  3. Does the synchronization boundary cover the entire read-modify-write operation?

For example, cache[key] = value appears to be a single line, but internally it may involve lookup, resizing, and writing. Adding markers separately before and after the call does not provide mutual exclusion. Likewise, “check that an array is not empty, then read its first element” must happen within the same isolation domain. Otherwise, another task can still empty the array between the check and the read.

Retain the .xcresult instead of capturing only the last few dozen terminal lines. Start by exporting a structured summary:

xcrun xcresulttool get test-results summary \
  --path Artifacts/tsan/result.xcresult \
  --format json > Artifacts/tsan/summary.json

Command availability varies with the installed Xcode version, so the script should first run xcrun xcresulttool help to verify the subcommand. Archive the result bundle together with the source commit, Scheme name, and simulator runtime version. Without all of them, reconstructing the failure later will be difficult.

Turn fix verification into a stable quality gate

After applying the fix, first rerun the original stress test and confirm that Sanitizer no longer reports a race. Then run the regular suite without instrumentation to ensure that the isolation changes have not introduced deadlocks, ordering changes, or timeouts. Code review should also verify:

  • whether unjustified uses of @unchecked Sendable have been removed;
  • whether mutable collections have only one write entry point;
  • whether converting callbacks to async could resume a continuation more than once;
  • whether Task.detached bypasses an existing Actor;
  • whether test doubles and global singletons share state between test cases;
  • whether failed jobs upload the complete .xcresult.

Thread Sanitizer detects races that actually occur at runtime, while Swift strict concurrency checking enforces isolation at compile time. Neither replaces the other. Keeping compiler warnings, concurrency stress tests, and Sanitizer results in separate stages makes the cause of each failure clearer. The ultimate goal is not merely to make the report disappear, but to give every piece of mutable state a single owner that can be explained and reviewed.

Frequently asked questions

Should Thread Sanitizer run on every commit?

Run a small concurrency-focused suite on each commit and place the full instrumented suite in a separate job. Sanitizer overhead makes universal coverage unnecessarily slow.

Does a passing run prove that the code has no data races?

No. It only means no conflicting access was observed during that execution. Repetition, deliberate task interleaving, and broader critical-path coverage improve detection.

Should a detected race be fixed with an Actor or a lock?

Prefer an Actor for related mutable application state. Use a lock only for a small synchronous critical section with clear ownership rules and measured performance needs.

Dedicated physical nodes

Deploy Cloud Mac workflows with OpsVM

Choose from three Apple Silicon configurations and six available nodes. Actual availability is based on the live status returned by the console.

Choose a configuration and order