Codable, SwiftConcurrency, Swift 6 - Fundamentally incompatible?

Adding to this - there are a whole bunch of SwiftUI-critical protocols where they need a way to play nicely with MainActor specifically.

To take an example, implementing Equatable on @MainActor perfectly meets the needs of SwiftUI functions - but wouldn't meet the declared needs.

It would be handy to be able to write a protocol that says

protocol MainIsFine {
@nonisolated_or_main func foo()
}

Perhaps they need to be new protocols in this style for Hashable/Identifiable/Equatable to avoid breaking changes, perhaps it could just be a breaking change...

No no, I wasn't critizing at all! It's a good question, I'm just not sure of the answer. There really isn't well-established terminology for this kind of thing...

I really do believe that it's hard to have a meaningful discussion about the language in this case. Using concurrency without the warnings results in invalid code that "works" in Swift 5 because the compiler isn't complaining. It's kinda like discussing generics with type-checking off. How can we reach conclusions that make sense?

If you want to make a more flexible protocol, you can!

protocol WorksWithAnyIsolation {
  func foo(isolation: isolated (any Actor)?)
}

@MainActor
class MyClass: WorksWithAnyIsolation {
  func foo(isolation: isolated (any Actor)? = #isolation) {
  }
}

let value = MyClass()

// this is what a callsite looks like
value.foo()

I was actually quite curious to try this, because I wasn't sure if the compiler would accept this conformance. But I'm very pleasantly surprised it does!

I think this can be a good pattern for new protocols that need to offer flexible isolation compatibility. SE-0420 certainly makes this seem like a possible migration. Would such a thing happen to other protocols like Codable? Given the compiler team has already expressed their general support for a way to make isolated types conform to non-isolated protocols, I would say the answer here is "definitely not".

CoreBluetooth should really be fixed. Hopefully it will, but that is out of our control. I filed FB14471032 about it though.

Thankfully, because this is a systemic problem, Swift has many features specifically designed to express missing isolation requirements like this. Here's the section in the migration guide that covers (I think) this situation:

https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/incrementaladoption#Unmarked-Sendable-Closures

I'm 100% confident that none of my real live shipping apps would pass muster from that architect.

Like most indie devs (I assume) - I ship the apps which would only pass as exploratory in a larger organisation...

I can take my model. Add @Observable to make it interact with SwiftUI fantastically. Add Codable to let me serialise*. Annotate with @MainActor to get the compiler to flush out most of my synchronisation issues.

This is awesome.

If you give me something more correct, but which is more painful to use. I'll pass until you force me not to.

*yes - I do have to manually write my codable conformance to play nicely with @Observable.

I just stumbled on something surprising which seems a lot easier...

A non-isolated function meets an isolated Protocol requirement.

protocol MainProtocol {
    @MainActor var id:UUID {get}
}

class Doc:MainProtocol {
    let id = UUID()
}

I didn't expect this to be ok.
It seems like this style would have been a better fit for SwiftUI Protocols*

"You can use a non-isolated object - or one which is isolated to MainActor"

I couldn't figure out how to do this with the WorksWithAnyIsolation approach you gave above...

*specifically Hashable, Identifiable, Equatable

Yeah that whole isolated parameter junk I put together was mostly to satisify my own curiosity. I've been thinking about it for a while and was just eager to try. You are right, it will not work with properties. I don't even know if it is a great idea in all cases.

I think it is interesting to note that SwiftUI's View isn't just MainActor-isolated, its isolation approach has been modified in the latests SDKs. Why? Because how it was originally written (like above, with an isolated requirement) was making split-isolation the default. And that was causing wide-spread issues because those kinds of types are extremely hard to work with.

Personally, I've had problems with NSTextStorageDelegate. It is non-isolated, and I think that is correct. It is ok to use this from off the MainActor. But, it's a real pain to use from a MainActor type, so I use @preconcurrency. This feels bad, because this isn't preconcurrency at all. But, effectively it does exactly what I want and it provides very strong runtime guarantees about isolation.

protocol MyProtocol {
	func foo()
}

@MainActor
class MyClass {
	var prop = 42

	init() {}
}

extension MyClass: @preconcurrency MyProtocol {
	func foo() {
		print(self.prop)
	}
}

This was already suggested somewhere here. But whatever - I think it's important you be able to design exactly what you want!

But, we also have to stay within the bounds of the language. I don't think it is a success to have something that works for you today but fails to compile with Swift 6. Which is why I have been harping so much on warnings. I wrote sooo much code I had to just plain throw away when I did this same thing. I wouldn't call it a disaster - I learned a lot. But it hurt, and I'd like to save others the pain if possible.

So, here's the thing. You are feeling like the language (or some core protocols) need to change to accomidate your reasonable uses. And I agree that in the abstract your uses are reasonable. But a big part of the disconnect that has been pervasive throughout this entire thread is we are, quite literally, not using the same language. You are using a dialect that, for better or worse, feels like it provides a smooth path torwards Swift 6 and in fact will do the exact opposite.

3 Likes

Can you achieve the equivalent with methods? I couldn't figure out how...

yes - that annotation is great for squashing warnings.

What I'd really like though is an isolation-aware serialisation solution. I don't want to be turning off warnings and YOLO-ing my way out of trouble...

1 Like

The example I provided includes that foo method, but I guess that wasn't enough?

Hmm ok well, here's a question. That @preconcurrency conformance trick is available today. Does that get you any further?

It can't readily access properties - which seems like a thing you would want to do...

@MainActor
class MyClass: WorksWithAnyIsolation {
    var title:String = ""
    func foo(isolation: isolated (any Actor)? = #isolation) -> String {
       //Main actor-isolated property 'title' can not be referenced on a non-isolated actor instance
        return title 
    }
}

not that I have really experimented with this approach.

Hmm ok well, here's a question. That @preconcurrency conformance trick is available today. Does that get you any further?

sure - it silenced a bunch of warnings.
Obviously it's now my job to make sure the protocol is only called on main (as it was before).

1 Like

Gah of course! This trick will only work with non-isolated classes. Oh well...

Yes you got it. However this approach will crash at runtime if you fail to do so (on the latest OSes). Dynamic isolation enforces the compile-time guarantees at runtime. And that is exactly how I assume it would function if the language adds some other kind of mechanism to do this.

I get this is not a) ideal and b) what you want. But, I think it is a fine tool to help you move forward towards the next problem you'll hit with a split-isolation class :wink:

1 Like

I see the wink - but there has been so much banging on about my split-isolation class that I have to emphasise...

I am in fact using split isolation. But that's a massive red-herring.

The fundamental issue I'm hitting applies if I have single isolation. I kind of wish I had never mentioned the split isolation...

That feels like something that should have been announced with a giant klaxon!

Checking I have understood you: if Codable calls from a background thread to

@MainActor
    public func encode(to encoder: Encoder) throws {

then on MacOS 15, that's a crash rather than a 'fingers crossed, it'll probably be fine...' ??

Is this a Swift6 thing - or something that'll bite on Swift5?

Yeah you're right. The reason I'm personally focused on it is I have yet to see a type like that used successfully. But by no means does that guarantee problems! You can pull off pretty much anything with preconcurrency/dynamic isolation.

My intuition is that, ultimately, you're going to settle on a plain old non-isolated class. And, yes, that does expose you to potential holes that insufficiently-annotated legacy APIs open up. But that's also exactly what dynamic isolation was made for.

I tried to find the discussion I had about this and I'm failing.

I think that the dynamic checks introduced by a @preconcurrency conformance require a Swift 6 standard library to crash at runtime. You need a Swift 6 compiler to use this feature, but language mode isn't a factor.

So, if you do this, and you get it wrong at runtime, it will not crash on older OSes. But, the type isn't Sendable, so the compiler is also going to make this a tough situation to get into.

But if you are worried about it, you can throw this in at the top of the functions:

MainActor.assertIsolated()

No, they don't; the specific feature that requires a newer standard library at runtime is SE-0424: Custom isolation checking for SerialExecutor which does impact the dynamic checking, but only for actors with custom executors. For @MainActor-isolated types, the dynamic isolation checking inserted for a @preconcurrency conformance does not depend on deployment target.

EDIT: I forgot that these diagnostics are runtime warnings by default on older deployment targets.

2 Likes

I wonder why a non-Sendable type with @MainActor properties implies that it can be created in another isolation domain and then be sent to main thread? From my understanding, since it's non-Sendable, it can only stay within the same isolation domain where it's created.

EDIT: I think it's conforming to Codable implies that the value can be created on any isolation domain because methods of the protocol are non-Isolated. On the other hand, declaring a non-Sendable type with @MainActor properties implies some properties should only be initialized in main thread. And hence the issue. That said, I found it's non-intuitive. I wouldn't realize these implications until I run into the issue.

Yes, that was exactly the problem I referred to.
Even without Codable, a class that is not made Sendable and not entirely marked @MainActor, but has some parts that are marked as such, is expressing: "I can be created in any isolation context, but then I have to stay in that context and cannot be send to another one. Oh, and there are also some parts of me which can only be accessed on the @MainActor's isolation context."

But if the @MainActor isolated "parts" are properties, how are they even instantiated on any other isolation context? That is a contradiction unless it's calculated properties that do not rely on any state that's not isolated in the same way (which then would also have to be initialized somehow), OR it's similarly non-isolation-dependent functions. I'd say the use-cases for such a class are limited. Making it work as defined would also require some way manually guarantee the isolation works, maybe via locks or the like.
However, the fun thing is that if you define such a class, the compiler will only ever warn you about that once you, somewhere in your code, "expose" that contradiction. If you only ever instantiate it in a function that is (directly or indirectly) bound to @MainActor, it will not scream (I think). In other words: It is perfectly valid to define a class with that "contradiction" as long as you do not "trigger" it.

Now, as soon as you make it adopt to Codable, you get the warning, as Codable's methods, by not being isolated to @MainActor, explicitly require you to access the parts that are isolated (in the initializer not the initializer, actually it's Encodable's func encode(to encoder: any Encoder) that requires access to the isolated parts...).
That is something earlier Swift simply did not care about, it was not expressible.

Btw, of course I am not saying that resolving this can be done by fixing the "contradiction" in the class's definition (by e.g. making it entirely @MainActor). That would still clash with Codable as the protocol is designed to be useful for types that run on any isolation context. @ConfusedVorlon is right, imo, that a more restrictive/"clever" protocol would be useful. What I am saying is that now that Swift has syntax and semantics to explicitly express these concurrency aspects (isolation, sendability, etc.), situations like this are unavoidable. You can mitigate some things with @preconcurrency and the like, but here that won't do. As written the code basically says "Yes, I will be using this unsendable, partially isolated class from multiple isolation contexts, including the isolated parts". The entire point of the "new world" of data race safety is that the compiler complains.

2 Likes

I can definitely understand that and I think it was very constructive that you didn't keep your mouth shut (as others have pointed out, this is definitely a valuable discussion to have).
Also, I feel that Swift generally tends to try to push adoption of updates more than perhaps other languages (just like Apple pushes users to update), so I see where you come from. Generally, though, I'd say that's a good thing, I know Java coders who'd wish it was so "natural" to always "aim for the HEAD"... :smiley:

That being said, I don't think there's too much reason to panic and feel like you're being left in the dust if you cannot adopt to all the Swift 6 data race safety goodness. Xcode 15 still has the 4 and 4.2 language modes for Swift available (I don't know which modes are in Xcode 16 as I unfortunately didn't have the time to check the betas out yet), so I'd hope it's safe to say we all have time to "fully" migrate to Swift 6.

This makes me laugh (in a dark, dark way...). :smiley: I do work for a large organization and I can assure you, we're probably way worse! I've seen shipped code that wanted me to poke my eyes out with forks. Stuff I'd have graded F during my uni days...

Having a document class that directly serializes without "picture perfect data separation" is probably very common and in my eyes a totally forgivable thing!

I just want to emphasize that bringing Swift to full data race safety is a very big thing, so if something that would ease this transition is not there yet, we should be equally forgiving.

5 Likes

I'm a bit out of my depth here, but I wonder if there is some conflating of 'things the compiler can easily reason about' and 'things that are actually safe'

Initialising a class feels like it would generally be 'actually safe'. The init function is a wrapper around the creation. Nothing is going to change the class during init (because nothing else knows about it).

Creation from codable would be an obvious example here.

Init passes back the class - and then never touches it again.

That's fine right?
(No implicit data-races that I'm ignoring at my peril???)

Note - I'm not saying this is guaranteed safe in every case. I can well imagine an init which called other functions in the class and was absolutely not safe.

No complaint here - I assume Swift has annotations to let me express "This is fine, I have thought about it".
My point is that creating a @MainActor class or properties from another isolation would be perfectly correct in many circumstances (even if the compiler can't prove that for me).

1 Like

I just wanted to add a Thank You to everyone who has chipped in here.

I have learned a bunch about Swift6 :slightly_smiling_face:

I really appreciate everyone taking the time to engage.

8 Likes

You are right, that was my bad, inits are a special case. I will update above post next.

Decodable's init(from: any Decoder) is actually fine, but Encodable's func encode(to encoder: any Encoder) illustrates the issue: You cannot access (and thus encode) your @MainActor isolated properties unless you make the entire method @MainActor, which clashes with what the protocol actually expresses (and results in the warning you see).

2 Likes