Another interesting `Sendable` bypass trick

Recently, I found yet another way to bypass Sendable check. The trick relies on the following behavior:

protocol A {
    func take<T>(_ t: T)
}

struct B: A {
    func take<T: Sendable>(_ t: T) { }
}

As shown above, when implementing a generic protocol requirement, the conformer can strengthen the generic constraint by adding T: Sendable. Normally, this is illegal if we just add a random protocol constraint, because theoretically it does not satisfy the Liskov substitution principle.

I'm not sure why Sendable is special, and I don't know if this is a bug or is by design. I certainly can come up with some situation where this can be helpful, for example when the protocol we want to conform to comes from a pre-concurrency library.

Based on this behavior, we can transform any non-Sendable value to be sendable:

protocol A {
    func asSendable<T>(_ t: T) -> any Sendable
}

struct B: A {
    func asSendable<T: Sendable>(_ t: T) -> any Sendable { t }
}

func foo<T>(_ input: T) {
    let transformed = (B() as A).asSendable(input)
    Task {
        _ = transformed
    }
}

1 Like

It’s by design that this happens in -swift-version 5, but it should not happen in Swift 6 mode.

However Sendable checking isn’t fully sound in either mode because of dynamic casts. You can erase any value to Any, and downcast to any Sendable, which always succeeds.

5 Likes