Sure, I wondered too, if this could just be a race condition, which would mean, the code is valid from the perspective of the compiler.
I didn't use TSAN to triple check yet. But debugging revealed, that access actually happens on different threads (not just different Tasks). Intuitively it looks wrong, and the compiler should emit an error (IMHO).
import Testing
class NonSendable {
var value: Int = 0
}
@MainActor
@Test
func testShouldNotHaveDataRaces() async throws {
let ns = NonSendable()
let task = Task {
// MainActor.shared.preconditionIsolated()
isolatedSend1(ns)
for _ in 0..<100_000 {
ns.value += 1
}
print("finished \(ns.value)")
}
await task.value
try await Task.sleep(nanoseconds: 1_000_000)
print("### value: \(ns.value)")
}
func isolatedSend1(
_ value: sending NonSendable,
isolated: isolated any Actor = #isolation
) {
// isolated.preconditionIsolated()
Task {
// should be isolated on 'isolated'
// isolated.preconditionIsolated()
print("current value when reset: \(value.value)")
value.value = -1
}
}
When run multiple times, we can observe already "strange" behaviour from the output in the console.
When run with TSAN enabled, I get this output:
Summary
swift test --sanitize=thread 1 β΅ agrosam@Andreass-MacBook-Pro-MAX
[1/1] Planning build
Building for debugging...
[7/7] Linking Xcode 26PackageTests
Build complete! (0.44s)
Test Suite 'All tests' started at 2025-07-05 14:50:36.095.
Test Suite 'All tests' passed at 2025-07-05 14:50:36.097.
Executed 0 tests, with 0 failures (0 unexpected) in 0.000 (0.002) seconds
τ Test run started.
τ΅ Testing Library Version: 1070
τ΅ Target Platform: arm64e-apple-macos14.0
τ Test testShouldNotHaveDataRaces() started.
==================
WARNING: ThreadSanitizer: data race (pid=88407)
Read of size 8 at 0x000104403c90 by thread T1:
#0 NonSendable.value.getter /<compiler-generated> (Xcode 26PackageTests:arm64+0x22fc)
#1 specialized closure #1 in isolatedSend1(_:isolated:) ConcurrencyBug.swift:258 (Xcode 26PackageTests:arm64+0x4e74)
#2 swift::runJobInEstablishedExecutorContext(swift::Job*) <null> (libswift_Concurrency.dylib:arm64e+0x5c450)
Previous write of size 8 at 0x000104403c90 by main thread:
#0 closure #1 in testShouldNotHaveDataRaces() ConcurrencyBug.swift:239 (Xcode 26PackageTests:arm64+0x44cc)
#1 swift::runJobInEstablishedExecutorContext(swift::Job*) <null> (libswift_Concurrency.dylib:arm64e+0x5c450)
#2 <null> <null> (swiftpm-testing-helper:arm64+0x100000870)
Location is heap block of size 24 at 0x000104403c80 allocated by main thread:
#0 malloc <null> (libclang_rt.tsan_osx_dynamic.dylib:arm64e+0x63874)
#1 _malloc_type_malloc_outlined <null> (libsystem_malloc.dylib:arm64e+0x1da80)
#2 testShouldNotHaveDataRaces() ConcurrencyBug.swift:234 (Xcode 26PackageTests:arm64+0x2d34)
#3 swift::runJobInEstablishedExecutorContext(swift::Job*) <null> (libswift_Concurrency.dylib:arm64e+0x5c450)
#4 <null> <null> (swiftpm-testing-helper:arm64+0x100000870)
Thread T1 (tid=63925807, running) is a GCD worker thread
SUMMARY: ThreadSanitizer: data race /<compiler-generated> in NonSendable.value.getter
==================
current value when reset: 153
==================
WARNING: ThreadSanitizer: data race (pid=88407)
Write of size 8 at 0x000104403c90 by thread T1:
#0 NonSendable.value.setter /<compiler-generated> (Xcode 26PackageTests:arm64+0x2368)
#1 specialized closure #1 in isolatedSend1(_:isolated:) ConcurrencyBug.swift:259 (Xcode 26PackageTests:arm64+0x5064)
#2 swift::runJobInEstablishedExecutorContext(swift::Job*) <null> (libswift_Concurrency.dylib:arm64e+0x5c450)
Previous write of size 1 at 0x000104403c90 by main thread:
#0 closure #1 in testShouldNotHaveDataRaces() ConcurrencyBug.swift:239 (Xcode 26PackageTests:arm64+0x44cc)
#1 swift::runJobInEstablishedExecutorContext(swift::Job*) <null> (libswift_Concurrency.dylib:arm64e+0x5c450)
#2 <null> <null> (swiftpm-testing-helper:arm64+0x100000870)
Location is heap block of size 24 at 0x000104403c80 allocated by main thread:
#0 malloc <null> (libclang_rt.tsan_osx_dynamic.dylib:arm64e+0x63874)
#1 _malloc_type_malloc_outlined <null> (libsystem_malloc.dylib:arm64e+0x1da80)
#2 testShouldNotHaveDataRaces() ConcurrencyBug.swift:234 (Xcode 26PackageTests:arm64+0x2d34)
#3 swift::runJobInEstablishedExecutorContext(swift::Job*) <null> (libswift_Concurrency.dylib:arm64e+0x5c450)
#4 <null> <null> (swiftpm-testing-helper:arm64+0x100000870)
Thread T1 (tid=63925807, running) is a GCD worker thread
SUMMARY: ThreadSanitizer: data race /<compiler-generated> in NonSendable.value.setter
==================
finished 99809
### value: 99809
τ Test testShouldNotHaveDataRaces() passed after 4.760 seconds.
τ Test run with 1 test passed after 4.761 seconds.
ThreadSanitizer: reported 2 warnings
error: Exited with unexpected signal code 6
Interestingly, the preconditions (if enabled) do not fail. I commented them out to reduce any potential interference with TSAN.
a closure literal passed to initialize a Task that is instantiated within a function with an isolated parameter will currently only inherit the isolation of the enclosing function if the function's isolated parameter itself is strongly captured by the closure. in the given examples this is why commenting out the isolation assertions induces a data race (the closure is no longer isolated to the isolated actor parameter), and un-commenting them causes the isolation preconditions to succeed.
if you wish to avoid the capture's causing the closure to become isolated, you can currently exploit a shortcoming in the language, and capture the isolated parameter as a capture list item:
func isolatedSend1(
_ value: sending NonSendable,
isolated: isolated any Actor = #isolation
) {
isolated.preconditionIsolated() // β
Task { [isolated] in // explicit capture currently breaks isolation inheritance
isolated.preconditionIsolated() // π₯
print("current value when reset: \(value.value)")
value.value = -1
}
}
in SE-420 these rules are stated as follows (emphasis mine):
According to SE-0304, closures passed directly to the Task initializer (i.e. Task { /*here*/ }) inherit the statically-specified isolation of the current context if:
the current context is non-isolated,
the current context is isolated to a global actor, or
the current context has an isolated parameter (including the implicit self of an actor method) and that parameter is strongly captured by the closure.
this conditional isolation inheritance has been brought up numerous times as a confusing and non-obvious behavior (here is a similar discussion from a relatively recent thread), but the motivation is reasonable β changing the functionality to implicitly capture isolated parameters could introduce non-obvious memory leaks (and presumably would have to be a change staged-in over time to avoid altering the semantics of existing code).
however, regarding the lack of an expected diagnostic[1], i suspect this is a shortcoming with region-based isolation analysis, and would recommend filing a bug report if you can find the time to do so.