Swift 6 upgrade Preparation

Michael Link
Jamf Engineering
Published in
12 min readApr 11, 2024

--

Swift 5 updates have been slowly building up to the release of Swift 6. Some of the major updates have been the addition of async/await (concurrency) and existentials. If you use any of these features there will be some significant changes that will require some refactoring. Continue reading to learn how to prepare your projects and packages before the release of Swift 6 so you can also take advantage of new features (such as Swift 5.10's full data isolation) and have a smooth easy transition without any disruptive refactoring.

Migration to Swift 6

As of this publishing there doesn’t yet exist a formal migration guide to Swift 6, but the committee has indicated one will be available when remaining work on language features has been completed. There is a forum post discussing progress toward the Swift 6 language mode that goes into some of the details involved in transitioning. Since Swift 5.8 piecemeal adoption of Swift 6 language features can be adopted on as needed basis. In my experience most refactoring work will revolve around the StrictConcurrency feature as that has continued to evolve through Swift 5.10 and introduces the most breaking changes.

Upcoming features that will be enabled by default in Swift 6

It seems that ExistentialAny (SE-0335: Introduce existential any) has been withdrawn from the original planned default behavior of Swift 6, but it is expected to be part of the language default at some future point (currently defined for Swift 7). If you’ve been keeping up to date with this feature then you most likely have already applied many of the Xcode fix-its for conformance. If this is the case for you my recommendation would be to keep the changes as is and not worry about reverting anything as that would likely be more tedious than the original fix-its. If there are any breaking changes introduced from ongoing evolution implementations I would deal with those changes at that time, most likely just another round of simple fix-its.

Preparing Xcode Projects for Swift 6

Xcode has a specific build setting Swift Concurrency Checking that should be set to Complete. Most likely turning this on will cause breaking changes that will need to be fixed before further builds will succeed.

Additional features can be adopted by setting the Other Swift Flags field in Xcode’s build settings. Be aware that the -enable-upcoming-feature and the actual feature name must be on separate lines.

Preparing Packages for Swift 6

Upcoming features can be turned on for Swift Packages that are using Swift 5.8 or greater. According to the piecemeal adoption proposal any features that become enabled by default in a future language version will be an error. This can be guarded against by using the preprocessor to enable features that are not yet already enabled.

An example implementation of a Package.swift file:

// swift-tools-version: 5.10

import PackageDescription

let package = Package(
name: "MySwiftPackage",
platforms: [.macOS(.v12)],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "MySwiftPackage",
targets: ["MySwiftPackage"]
),
.library(
name: "MySwiftPackageTest",
targets: ["MySwiftPackageTest"]
)
],
dependencies: [
.package(url: "https://github.com/jamf/Haversack.git", .upToNextMajor(from: "1.0.0"))
],
targets: [
.target(
name: "MySwiftPackage",
dependencies: [
.product(name: "Haversack", package: "Haversack"),
]
),
.target(
name: "MySwiftPackageTest",
dependencies: ["MySwiftPackage"]
),
.testTarget(
name: "MySwiftPackageTests",
dependencies: ["MySwiftPackageTest"]
)
]
)

// If future target types are also incompatible with swiftSettings then
// the where test will need to be updated.
for target in package.targets where target.type != .binary {
var swiftSettings = target.swiftSettings ?? []

// According to Swift's piecemeal adoption plan features that were
// upcoming features that become language defaults and are still enabled
// as upcoming features will result in a compiler error. Currently in the
// latest 5.10 compiler this doesn't happen, the compiler ignores it.
//
// If the situation does change and enabling default language features
// does result in an error in future versions we attempt to guard against
// this by using the hasFeature(x) compiler directive to see if we have a
// feature already, or if we can enable it. It's safe to enable features
// that don't exist in older compiler versions as the compiler will ignore
// features it doesn't have implemented.

// swift 6
#if !hasFeature(ConciseMagicFile)
swiftSettings.append(.enableUpcomingFeature("ConciseMagicFile"))
#endif

#if !hasFeature(ForwardTrailingClosures)
swiftSettings.append(.enableUpcomingFeature("ForwardTrailingClosures"))
#endif

#if !hasFeature(StrictConcurrency)
swiftSettings.append(.enableUpcomingFeature("StrictConcurrency"))
// StrictConcurrency is under experimental features in Swift <=5.10 contrary to some posts and documentation
swiftSettings.append(.enableExperimentalFeature("StrictConcurrency"))
#endif

#if !hasFeature(BareSlashRegexLiterals)
swiftSettings.append(.enableUpcomingFeature("BareSlashRegexLiterals"))
#endif

#if !hasFeature(ImplicitOpenExistentials)
swiftSettings.append(.enableUpcomingFeature("ImplicitOpenExistentials"))
#endif

#if !hasFeature(ImportObjcForwardDeclarations)
swiftSettings.append(.enableUpcomingFeature("ImportObjcForwardDeclarations"))
#endif

#if !hasFeature(DisableOutwardActorInference)
swiftSettings.append(.enableUpcomingFeature("DisableOutwardActorInference"))
#endif

#if !hasFeature(InternalImportsByDefault)
swiftSettings.append(.enableUpcomingFeature("InternalImportsByDefault"))
#endif

#if !hasFeature(IsolatedDefaultValues)
swiftSettings.append(.enableUpcomingFeature("IsolatedDefaultValues"))
#endif

#if !hasFeature(GlobalConcurrency)
swiftSettings.append(.enableUpcomingFeature("GlobalConcurrency"))
#endif

// swift 7
#if !hasFeature(ExistentialAny)
swiftSettings.append(.enableUpcomingFeature("ExistentialAny"))
#endif

target.swiftSettings = swiftSettings
}

Advice for Updating Packages for Strict Concurrency

Audit your package to add concurrency annotations. Most of this work will involve adding Sendable conformance to types. A Sendable type can be safely passed across concurrency domains. Updating packages with concurrency annotations allows the compiler to check for potential data races and other concurrency problems at compile time. Even if static checking is turned off by using features such as nonisolated(unsafe) or @unchecked Sendable the thread-analyzer can still detect violations and report them during runtime.

  • Add conformance to Sendable for types even if they are implicitly Sendable such as structs. When your package is imported the compiler doesn’t implicitly infer whether the type is Sendable and Xcode will display a warning. Adding Sendable conformance will typically cause breaking changes for consumers of the library, I would recommend using SemVer to version the update as a major new version.
  • If you need to use @unchecked Sendable then be sure the type is actually safe to use as a Sendable type. Class types that already implement their own internal synchronization to protect their mutable state can safely use @unchecked Sendable but be aware this turns off static checking by the compiler. In the rare event a new type needs to be created that can’t be an actor type then implementing some type of synchronization should be done. This can be accomplished with a serial DispatchQueue or other types of locks as you see fit.
  • If the class has a complex inheritance/subclass hierarchy then you may consider refactoring it using actors and switching to a composite design pattern since actors cannot be subclassed but ultimately that is a subjective decision. One advantage of converting to an actor type is that the compiler can check for concurrency issues that may otherwise be missed in a class marked as @unchecked Sendable.
  • Convert methods that take completions to be async instead. Avoid overuse of Task { } when converting a method to be async as this can introduce unintended hops out of a context you did not previously expect. Convert any methods that call the new async method to also be async and continue this until all callee methods are async if necessary. If the method that has a completion handler doesn’t have a complex call hierarchy then adding an async alternative should be a simple process.
  • Consider converting classes that use a serial DispatchQueue to an actor type. Actor types are implicitly Sendable and the reduction in code complexity can be significant. Actor types also undergo static analysis by the compiler for concurrency issues that would otherwise be ignored from a class that uses @unchecked Sendable.

Solutions to Help Migration

Some features have Xcode fix-its that can be mass applied very easily, other features that enable Strict Concurrency can cause breaking changes that may require some non-trivial refactoring. Most if not all the solutions mentioned below will be specific for adopting Strict Concurrency since that’s where the most headaches will occur. It may be helpful to read the proposal On Actors and Initialization to have some familiarity with changes to how init and deinit isolation/nonisolation is implemented.

Convert Methods that Use Completion Handlers to Async Methods

Convert Function to Async and Add Async Wrapper are typically the better options. Add Async Alternative refactors the original method to wrap its call in a Task { } and call the async version, this may introduce an unintended hop where it would otherwise not be needed. I would recommend re-implementing a new async version of the method instead.

Conversion to an async method can be achieved without the helpers by using a CheckedContinuation or an UnsafeContinuation see the following methods:

Continuations are a way to interface synchronous code with asynchronous code.

Global or Static Var is Not Concurrency-Safe in a Non-Isolated Context

  1. If possible convert the global or static to a let constant and also add Sendable conformance to the type.
  2. If the value is only used within a certain concurrency domain then add a global actor instance type attribute to its declaration (e.g. add @MainActor attribute to the declaration).
  3. Audit the type and if it’s determined its use is actually concurrency-safe then nonisolated(unsafe) can be used.

Actor-Isolated Property Can Not Be Referenced from a Non-Isolated Context (deinit)

This has always been an error when attempting to modify an actor’s property from outside the actor context or from a nonisolated actor method. In newer versions of Swift this will appear as a warning inside a deinit method on an actor because the actor’s deinit method is inherently nonisolated, solving this problem can be quite tricky and an obvious use of Task { } could lead to a situation where self is captured after deallocation, typically resulting in a crash of some sort. There are evolution proposals to fix this problem but until then we must resort to the tools we have.

Consider the following actor definition:

public actor BackgroundScheduler {
private lazy var instanceSchedulers = [String : NSBackgroundActivityScheduler]()

public init() {}

deinit {
instanceSchedulers.forEach { $0.value.invalidate() }
}
}

The property instanceSchedulers cannot be accessed from the nonisolated deinit method, at least in Swift 6 that will be an error. If the value is Sendable or has its own synchronization for protecting mutable state then we can assume isolation in the deinit method and safely cleanup what we need to (note in this case it should be reasoned that NSBackgroundActivityScheduler is thread-safe which according to the documentation it uses a serial queue so we will assume it is).

The following code example shows how to assume isolation in a deinit method:

public actor BackgroundScheduler {
private lazy var instanceSchedulers = [String : NSBackgroundActivityScheduler]()

public init() {}

deinit {
// use the following method to avoid a hop outside of this deinit, this is probably safe because even though something else could capture the `NSBackgroundActivityScheduler` instance we are assuming that its `invalidate()` method is synchronized internally
assumeIsolatedDuringDeinit { actor in
actor.instanceSchedulers.forEach { $0.value.invalidate() }
}
}
}

extension BackgroundScheduler {
// An actor's deinit is nonisolated so we need to cleanup state that needs to exist past this instance's deinit. Currently there is no way to accomplish this that wouldn't be an error in Swift 6, hopefully that changes at some point. This evolution proposal attempts to address this problem: https://github.com/apple/swift-evolution/blob/main/proposals/0371-isolated-synchronous-deinit.md
// Method influenced from https://github.com/apple/swift/blob/47803aad3b0d326e5231ad0d7936d40264f56edd/stdlib/public/Concurrency/ExecutorAssertions.swift#L351
@_unavailableFromAsync(message: "express the closure as an explicit function declared on the specified 'actor' instead")
private nonisolated func assumeIsolatedDuringDeinit<T>(_ operation: (isolated BackgroundScheduler) throws -> T) rethrows -> T {
typealias YesActor = (isolated BackgroundScheduler) throws -> T
typealias NoActor = (BackgroundScheduler) throws -> T

// To do the unsafe cast, we have to pretend it's @escaping.
return try withoutActuallyEscaping(operation) { (_ fn: @escaping YesActor) throws -> T in
let rawFn = unsafeBitCast(fn, to: NoActor.self)
return try rawFn(self)
}
}
}

Conversely you may have a need to assume isolation in init and this same technique can work in that case also. Be careful to disambiguate from the library provided assumeIsolated method if you decide to generalize your implementations.

Custom Actor Executors (using DispatchQueue)

One reason to implement a custom actor executor is to adopt the same synchronization that a non-actor type uses. This is useful in cases where a library type uses a delegate pattern and synchronizes itself on DispatchQueue supplied by the enclosing type. CBPeripheralManager in the CoreBluetooth framework is one such type that can take advantage of a custom actor executor and the following example will illustrate that with a partial implementation.

First define a class that conforms to SerialExecutor , implementation will be slightly different on newer OS versions.

extension BluetoothBeaconService {
final class SerialDispatchQueueExecutor: SerialExecutor {
private let queue: DispatchQueue

init(queue: DispatchQueue) {
self.queue = queue
}

func enqueue(_ job: UnownedJob) {
let unownedExecutor = asUnownedSerialExecutor()

queue.async {
job.runSynchronously(on: unownedExecutor)
}
}

// @available(macOS 14.0, iOS 17.0, *)
// func enqueue(_ job: consuming ExecutorJob) {
// let unownedJob = UnownedJob(job)
// let unownedExecutor = asUnownedSerialExecutor()
//
// queue.async {
// unownedJob.runSynchronously(on: unownedExecutor)
// }
// }

// this has a default implementation in macOS 14.0
func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}
}

Next define a method that will assume the delegate callbacks from a CBPeripheralManager are isolated to the DispatchQueue used by both instances. This is necessary because the delegate protocol must conform to nonisolated methods and this is safe because the callbacks occur on the serial dispatch queue. Notice that this method performs a check to make sure execution is taking place on the correct dispatch queue.

extension BluetoothBeaconService {
// this is very similar to `assumeIsolated` in the public Swift stdlib execpt we want to assume we are isolated on the same DispatchQueue without having a Task spun up. https://github.com/apple/swift/blob/47803aad3b0d326e5231ad0d7936d40264f56edd/stdlib/public/Concurrency/ExecutorAssertions.swift#L351
@_unavailableFromAsync(message: "express the closure as an explicit function declared on the specified 'actor' instead")
private nonisolated func assumeIsolatedToQueue<T>(_ operation: (isolated BluetoothBeaconService) throws -> T) rethrows -> T {
typealias YesActor = (isolated BluetoothBeaconService) throws -> T
typealias NoActor = (BluetoothBeaconService) throws -> T

dispatchPrecondition(condition: .onQueue(queue))

// To do the unsafe cast, we have to pretend it's @escaping.
return try withoutActuallyEscaping(operation) { (_ fn: @escaping YesActor) throws -> T in
let rawFn = unsafeBitCast(fn, to: NoActor.self)
return try rawFn(self)
}
}
}

Now the entire actor implementation and delegate methods can be completed. Note that the CBPeripheralManagerDelegate methods are nonisolated in order to satisfy the protocol requirements and this is one of the places assumeIsolatedToQueue is used because it is assumed the callbacks occur on the serial dispatch queue. If the internal implementation ever changes the dispatchPrecondition will catch this during development.

actor BluetoothBeaconService: NSObject {
private let queue = DispatchQueue(label: "beacon.control", qos: .userInitiated)
nonisolated var unownedExecutor: UnownedSerialExecutor {
executor.asUnownedSerialExecutor()
}
// we need to maintain a reference to our executor to keep it alive
private nonisolated let executor: SerialDispatchQueueExecutor

private var btManager: CBPeripheralManager!
private let beaconData: BeaconData
var canAdvertise = false
private var _isAdvertising = false
nonisolated var isAdvertising: Bool {
// since we have direct access to the queue we can maintain the original interface where this didn't require an await
queue.sync {
assumeIsolatedToQueue { actor in
actor._isAdvertising
}
}
}

init(beaconData: BeaconData) {
executor = SerialDispatchQueueExecutor(queue: queue)
self.beaconData = beaconData
super.init()
btManager = CBPeripheralManager(delegate: self, queue: queue)
}

deinit {
btManager.stopAdvertising()
}

func start() {
advertiseIfNeeded()
}

func stop() {
btManager.stopAdvertising()
_isAdvertising = false
}

func advertiseIfNeeded() {
if canAdvertise && !_isAdvertising {
btManager.startAdvertising(beaconData.advertisementDictionary)
}
}
}

extension BluetoothBeaconService: CBPeripheralManagerDelegate {
nonisolated func peripheralManagerDidUpdateState(_ peripheralManager: CBPeripheralManager) {
assumeIsolatedToQueue { actor in
actor.canAdvertise = (peripheralManager.state == .poweredOn)

if actor.canAdvertise {
actor.advertiseIfNeeded()
} else {
actor._isAdvertising = false
}
}
}

nonisolated func peripheralManagerDidStartAdvertising(_ peripheralManager: CBPeripheralManager, error: (any Error)?) {
assumeIsolatedToQueue { actor in
if let error {
actor._isAdvertising = false
} else {
actor._isAdvertising = true
}
}
}
}

Now the actor type is conveniently synchronized with the same serial dispatch queue one of its properties uses. This reduces excessive hopping from awaiting new Task blocks that would otherwise need to be used in the delegate callbacks.

Conclusion

Although migration to Swift 6 is entirely optional there are benefits to opting into features that will eventually be language defaults such as additional static analysis by the compiler. At the minimum you can begin writing code today that conforms to future default features that will reduce the amount of eventual refactoring required to adopt those features at a later date.

Further Reading

  • If you’re curious what features are available in the main branch or a specific release of Swift you may be interested in a helpful script from Ole Begemann.

--

--