Crash in Combine Related to Swift 6 Concurrency

I was writing a test for a custom Publisher, and to simplify the asynchrony inherent in the test I marked the test function as @MainActor (that way closures can write back to reference captured local values without racing). The primary closure I needed to inherit the @MainActor context of the function was the sink callback on the publisher. I just wrote it as sink { ... } and could mutate the local value in the closure without a compiler error about mutation from a concurrent access.

But then I got confused. How can this possibly work? The closure is inheriting the @MainActor context, but Combine is pre-concurrency, publishers generally just call receive(input:) synchronously and from whatever thread a value happens to come in on. How is it going to ensure the closure gets called from the MainActor?

Well the answer is: by crashing LOL.

I wasn't sure if this was the fault of my own publisher, so I stripped down the test a bare minimum that uses a PassthroughSubject, and I was able to reproduce the crash. Here's the code for it:

import Combine
import XCTest

struct SendableWrapper<T>: @unchecked Sendable {
    let value: T
}

@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
final class TestCombineCrash: XCTestCase {
    @MainActor
    func testCombineCrash() async throws {
        var state = 0
        
        let publisher = PassthroughSubject<Int, Never>()
        
        let subscription = publisher.sink { value in
            state += 1
        }
        
        Task.detached { [publisher = SendableWrapper(value: publisher)] in
            publisher.value.send(0)
        }
        
        try await Task.sleep(nanoseconds: 1_000_000_000_000)
    }
}

From testing I determined that it basically crashes if the actor promises of the code aren't actually satisfied at runtime. That means: if you mark the sink closure as @Sendable, it stops crashing. Of course that also (correctly) triggers the concurrent mutation compiler error. If you mark sink as @Sendable @MainActor, it crashes again. If you send the value from the main thread (omit the Task.detached wrapper), it works with the bare closure, since it happens to publish from the main thread.

Something else I found that is really fascinating to me, and this is why that SendableWrapper is necessary. With the bare closure, if I just try to call the publisher directly from inside Task.detached, I get this error:

Main actor-isolated value of type '() async -> ()' passed as a strongly transferred parameter; later accesses could race

But then if I mark the sink closure as @Sendable, the error goes away. So somehow the compiler is trying to prevent this situation from occuring, and getting the crash requires smuggling something through an @unchecked Sendable (here it's the publisher, in the test for my custom publisher, which is Sendable, inside the publisher's Subscription I was smuggling the upstream Subscriber across async contexts with the same trick, but my understanding is that a subscriber in Combine is supposed to be thread-safe so this is okay).

But how does this work!? What is the compiler using to determine that this error should occur only if sink takes in a closure not marked as @Sendable? How does it know to look there for what the rules are about use of the publisher? The publisher isn't captured by the closure, it's just the receiver of the message the closure it passed to. I'm only barely starting to learn about sending and actor-isolated regions so I figure it has something to do with this but I don't see any use of sending added to Combine, so it's a total mystery to me.

Anyways, I'm not sure if the crash is an oversight or an intentional "serves you right for using @unchecked Sendable" behavior of Swift 6.

2 Likes

I've run into the same problem, and you don't really need @unchecked Sendable to trigger it either, you just need old-fashioned APIs that don't understand Swift concurrency. For instance, I managed to make this minimal test case:

final class TestCrash: XCTestCase {
    let subject = CurrentValueSubject<Int, Never>(1)

    @MainActor
    func testCrash() async throws {
        let cancellable = subject.sink { value in
            print("\(value)")
        }

        performSelector(inBackground: #selector(triggerSubject), with: nil) // Crashes
        //perform(#selector(triggerSubject), with: nil) // Does not crash

        try await Task.sleep(nanoseconds: 1000000000)

        _ = cancellable
    }

    @objc func triggerSubject() {
        subject.send(2)
    }
}

I feel Swift is being a bit too over-eager to first apply @MainActor and then defend it here.

I run in the same issue. To borrow your test case


@globalActor
public struct TestGlobalActor {
    public actor ActorType { }
    public static let shared: ActorType = ActorType()
}

final class TestCrash: XCTestCase {
    let subject = CurrentValueSubject<Int, Never>(1)

    @TestGlobalActor
    func testCrash() async throws {
        let cancellable = subject.sink { value in
            print("\(value)")
        }

        performSelector(inBackground: #selector(triggerSubject), with: nil) // Crashes
        //perform(#selector(triggerSubject), with: nil) // Does not crash

        try await Task.sleep(nanoseconds: 1000000000)

        _ = cancellable
    }

    @objc func triggerSubject() {
        subject.send(2)
    }

As shown, this issue isn’t limited to MainActor. If a Combine signal is sent from a different actor context than where the subscription was created, it will crash.

Notably, this behavior only occurs in Swift 6; in Swift 5, it does not crash.

Is there any known workaround for this issue?

I think the reason it crashes is because the closure that handles incoming values inherits the isolation of the testCrash function by default, even though Combine makes no guarantee about which thread or actor it lands on.

The workaround for me is to annotate the sink closure with @Sendable to explicitly clear any inherited isolation.

Yes, any capture of closure parameters into Combine APIs can trigger Swift 6 mode's runtime queue assertions. For example:

publisher
  .filter { _ in true }
  .sink { _ in  }

If this was called from a @MainActor context, the closures passed to filter and sink would have assertions added that they're called from the main queue, even though they don't access any @MainActor isolated state. So if the publisher has a value sent from a different queue, the app will crash. You can add an explicit receive(on:) before calling any closure parameters, add @Sendable in to the closures you capture, or ensure you only send values from the expected queue.

publisher
  .filter { @Sendable _ in true }
  .receive(on: DispatchQueue.main)
  .sink { _ in  }
2 Likes