Expected a compiler error

The following code allows the use of a non-sendable value after being passed as a sending parameter:

class NonSendable {
    var value: Int = 0
}

func isolatedSend(
    _ value: sending NonSendable, 
    isolated: isolated any Actor = #isolation
) { }

// @Test
@MainActor
func testShouldEmitCompilerError() async throws {
    let ns = NonSendable()
    let task = Task {
        MainActor.shared.preconditionIsolated()
        isolatedSend(ns) // Expected Error: Sending 'ns' risks causing data races
        print(ns) // use ns
    }
    await task.value
}

The non-sending value ns will be used after it has been passed as a sending parameter to the function isolatedSend(_:isolated).

Note, that the function has a parameter isolated any Actor.

IMHO, the Swift compiler should emit an error, such like

Sending 'ns' risks causing data races
'ns' used after being passed as a 'sending' parameter; Later uses could race

Please correct me, if I'm wrong.

Note:
When the function has no parameter isolated any Actor, the Swift compiler emits an Error, as expected.

So it’s not being sent off the actor, isn’t it? Function is isolated to the same actor. Making it perfectly safe to access afterwards.

It can be sent off the actor in here:

func isolatedSend(_ value: sending NonSendable, isolated: isolated any Actor = #isolation) {
    Task {
        value.value = -1
    }
}

The code below will produce a data race, accessing ns simultaneously on different threads:

@MainActor
@Test
func testA() async throws {
    let ns = NonSendable()
    let task = Task {
        isolatedSend(ns)
        for _ in 0..<100_000_000 {
            ns.value += 1
        }
    }
    
    ns.value = 0
    await task.value
    print("### value: \(ns.value)")
}
2 Likes

You’re right, I forgot that isolation isn’t carried with isolated parameter inside Task.

1 Like

Thanks for looking more deeply into it :slight_smile:

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).

Maybe, your first intuition (and mine, too) that an isolated any Actor parameter should carry over to a Task should actually be the correct behaviour?

So,

func isolatedSend(
    _ value: sending NonSendable, 
    isolated: isolated any Actor = #isolation
) {
    Task {
        // should be isolated on 'isolated'
        isolated.preconditionIsolated()
        value.value = -1
    }
}

This would be more aligned with a corresponding method which is isolated accordingly to self:

@MainActor 
struct Foo {
    func isolatedSend(_ value: sending NonSendable) {
        Task {
            // should be isolated on 'MainActor'
            MainActor.shared.preconditionIsolated()
            value.value = -1
        }
    }
}

This is indeed a missing feature β€” Closure isolation control
To my understanding, there is just no mechanism yet to achieve this.

I did more testing, and utilised TSAN.

This is the test:

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.


  1. specifically in the first example there should presumably be a 'use after send' diagnostic produced β†©οΈŽ

4 Likes