Maybe Error should not imply Sendable

for the longest amount of time, i thought that

settings.append(.enableUpcomingFeature("StrictConcurrency"))

would enable strict concurrency checking. this was apparently fake news, because the correct setting is

settings.append(.enableExperimentalFeature("StrictConcurrency"))

quite predictably, i was hit by a busload of Sendable warnings. most of them were in places i had thought the compiler should really have been emitting warnings already but for some reason was not.

but some of the warnings present real logical dilemmas. here’s an example:

public
protocol JSONDecodable { ... }

extension JSONDecodable 
    where Self:RawRepresentable, RawValue:JSONDecodable
{
    @inlinable public
    init(json:JSON.Node) throws
    {
        let rawValue:RawValue = try .init(json: json)
        if  let value:Self = .init(rawValue: rawValue)
        {
            self = value
        }
        else
        {
            throw JSON.ValueError<RawValue, Self>.init(
                invalid: rawValue)
    // type 'Self.RawValue' does not conform to the 'Sendable' 
    // protocol
        }
    }
}

this happens because Error: Sendable.

extension JSON
{
    @frozen public
    struct ValueError<Value, Cases>:Error where Value:Sendable
    {
        public
        let value:Value
    }
}

this seems to make the concept of a generic error type quite useless. it would not make any sense to constrain Self.RawValue to Sendable, because JSON decoding has nothing to do with concurrency. and yet without it, we have no way of generically reporting a decoding failure.

perhaps Error should not imply Sendable?

1 Like

If your RawValue wasn’t Sendable and some async throws client function f of JSONDecodable.init(json:) was called across an isolation boundary, and f decided to pass all errors from JSONDecoder to that cross-isolation caller, you would have a Sendable violation at the point where the error is caught.

1 Like

i think this is a great example of a place where typed throws would be helpful.

the reason i have a Sendable violation is because Task<Success, any Error> is not strict about any Error & Sendable. if we had typed throws that could express throws(any Sendable), we could enforce this like a function color.

2 Likes

In that direction I imagine we’d still want the default to be that throws alone implies Sendable otherwise any untyped throwing function could not have its thrown errors propagated across an isolation boundary—any callers which could potentially do so would have to catch the error and throw a new, Sendable error.

1 Like

that’s not contradictory. we could let Error provide an implicit Sendable, much like Copyable. we could use the :Error, ~Sendable spelling to opt-out of the implicit Sendable.

3 Likes

I believe in long term errors should be Sendable. Very often errors are passed between concurrency domains and application layers. Error can be wrapped by another error type, shown to user, put to network response, logged etc.
So sendability of errors seems to be wise decision in most cases.

The main pitfall is Error protocol has legacy _userInfo: [String: Any] which can not be sendable because of Any. Typically some sort of userInfo is useful for errors.
I use workaround something like this:

public struct ErrorInfo: Sendable {
  public typealias ValueType = Sendable & Equatable & CustomStringConvertible
  
  internal private(set) var storage: [String: any ValueType]
}
1 Like