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.

1 Like

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.