Skip to content
C.W.K.
Stream
Lesson 04 of 05 · published

Swift 6 at the Boundary

~15 min · swift, swift-6, concurrency, sendable, isolation

Level 0Blank Canvas
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

What the Bridge Does Not Need

The engine package compiles in Swift 6 language mode (swiftLanguageModes: [.v6]) and so does the app (SWIFT_VERSION: "6.0"). It would be natural to expect the libmypaint bridge to bristle with @Sendable. The outline for this very track said so. It has none, and that is correct, for reasons worth spelling out:

  • The callbacks are C function pointers, not closures carrying state: two top-level functions that capture nothing and recover the store from the context pointer. Capturing nothing does not make them nonisolated, though. Swift 6 will turn a capture-free closure that touches main-actor state into a @convention(c) pointer without complaint (Swift 6.4 compiled one while this lesson was revised). These two are nonisolated for a plainer reason: they are top-level functions in an engine module that sets no default actor, and they touch only what the context hands them.
  • They run synchronously on the calling thread. libmypaint asks for tiles inside mypaint_brush_stroke_to and ard_surface_end, both called by Swift, and no callback outlives the call that triggered it.
  • The engine types they touch (TileStore, Brush, BrushSurface, Painter) are plain classes, not Sendable, with no actor annotation. Their doc comments say each lives "on the thread that owns the artwork", and Swift 6 enforces the spirit of that: a non-Sendable object cannot be sent into another isolation domain while anyone else still holds it. In the app the owner is CanvasModel, which is @MainActor.
  • The only parallel C is in Ariandel's pixel kernels, which spread loops across cores with dispatch_apply_f and return when every core is done. From Swift's side that is one synchronous call.

A synchronous C callback on the caller's thread is the easy case. Swift 6 has something to say where work changes threads, and Ariandel has exactly three kinds of place where it does. Each carries an annotation the compiler cannot verify, and each is worth reading as a sentence: what does it assert, and what would make it false?

Site 1: FrozenTile, an Unchecked Promise

TileBuffer is a class around a mutable pixel pointer. It is not Sendable, rightly. But a save must not copy every painted tile on the main thread (track 3 tells the incident that taught this), so an archive freezes tiles instead. FrozenTile wraps a buffer frozen at init and declares @unchecked Sendable; ArtworkArchive, built from them, is an ordinary Sendable struct. The canvas builds one on the main actor ("Freezing the tiles is cheap; encoding and writing happen off the main thread") and hands it to an actor, ArtworkWriter.

It asserts that once frozen, nobody writes those pixels again. Not a lock: an invariant. Three things keep it. writableTile is the only path to a writable buffer and copies a frozen one first; TileBuffer.fill preconditions that it isn't frozen; libmypaint's read-only requests never write. It breaks the moment any code writes through tile.pixels on a buffer it did not get from writableTile. That pointer is right there, typed mutable, and neither the compiler nor the precondition sees a raw store. Hence the house rule, verbatim: never write tile.pixels you did not get from writableTile or allocate yourself.

Site 2: nonisolated ARKit Delegates

The Reference window has a Face Paint mode, Procreate's idea: the front camera finds a face and wears the canvas as its skin, live as you paint. Its view is FaceReferenceView, an ARSCNView. Being a UIView, it is main-actor isolated. Two of its ARSCNViewDelegate methods are called by SceneKit on its render thread, so they are marked nonisolated: each promises not to touch main-actor state, and the compiler holds it to that. Reaching for model or the timer inside them would not compile. The one thing they do need, the face's material, is declared nonisolated(unsafe) private let skin. There the compiler stops checking, and the comment carries the claim instead: SceneKit reads it on its render thread; only the main thread changes its picture.

It asserts that the render thread only hands the material to new geometry, while the main thread only swaps its contents. It breaks if those methods start reaching further, or if the swap stops being something SceneKit tolerates; that half of the promise rests on SceneKit, not on Swift. The annotation is not decoration. Ariandel's house rules carry the family's scar: closures handed to C or Objective-C callbacks that fire off the main thread must be @Sendable or nonisolated. In September a sibling app, Forge, shipped a TestFlight build whose audio-tap closure was created inside a @MainActor method. Swift 6 inferred the closure main-actor isolated and compiled a run-time isolation check into it; the audio engine called it on its realtime thread, and the check trapped. That happened on Dad's phone, at the first tap of the microphone, while 74 tests stood green on the Simulator and green again on a cabled iPad. The Simulator would even have taken the Mac's audio input; what was missing was a test that started a real dictation turn, so nothing fired the tap before Dad did.

Site 3: MainActor.assumeIsolated

Timer.scheduledTimer and NotificationCenter.addObserver(forName:object:queue:) take closures whose types promise nothing about the main actor. Yet a timer scheduled from the main thread fires on the main run loop, and queue: .main delivers on the main queue. MainActor.assumeIsolated lets the closure say so: it runs its body as main-actor code if the current executor really is the main one, and stops the program if not. Ariandel has nine call sites, among them QuickShape's rest timer (armQuickShape), Liquify's hold timer, the face reference's refresh timer, and the memory-warning observer.

It asserts that the closure is running on the main thread right now. It breaks if the same timer is ever scheduled from a background thread (it then fires on that thread's run loop) or if queue: .main becomes nil (delivery on the posting thread). Either way the failure is loud, a trap at the first wrong call rather than a quiet race. That is the whole reason to prefer it over silencing the checker.

SiteAnnotationAssertsFalse if
libmypaint tile callbacksnone (nonisolated top-level functions, passed as @convention(c))runs inside a call Swift made, on its threadC ever calls back later, or from another thread
FrozenTile@unchecked Sendablea frozen buffer is never writtenpixels are written without writableTile
Face reference delegatesnonisolated, nonisolated(unsafe)the render thread only uses the materialthe methods reach other state
Timers, the memory observerMainActor.assumeIsolateddelivered on the main threadscheduled off main, or queue: nil
Every unchecked annotation is a sentence the compiler cannot read, so write the sentence next to it. FrozenTile's doc comment and the comment on skin do exactly that. A reviewer can then check the sentence against the code, which is the only check that annotation will ever get.

Code

Site 1: FrozenTile (verbatim) and the Sendable archive built from it (trimmed)·swift
// AriandelEngine/Tiles.swift (verbatim)
/// A tile that will never be written again, so any thread may read it.
///
/// The only way to make one is to freeze the buffer (`TileBuffer.freeze`),
/// which the thread that owns the artwork does before handing it over; after
/// that no one writes the pixels. That invariant, not a lock, is what makes
/// the unchecked conformance true.
public struct FrozenTile: @unchecked Sendable {
    public let buffer: TileBuffer

    public init(_ buffer: TileBuffer) {
        self.buffer = buffer.freeze()
    }
}

// AriandelEngine/ArtworkFile.swift (trimmed)
/// A frozen, thread-safe picture of an artwork: what a save writes, an
/// export encodes, and a load reads.
///
/// Taking an archive freezes the live tiles instead of copying them (see
/// `TileBuffer`), so it is cheap on the artwork's thread however large the
/// painting; encoding, compressing and writing then run anywhere while
/// painting goes on, and the next stroke copies only the tiles it touches.
public struct ArtworkArchive: Sendable {
    // ... layers carry [(key: TileKey, tile: FrozenTile)] ...
}
Sites 2 and 3: the nonisolated SceneKit delegates and the main-thread timers and observer (trimmed and verbatim excerpts from the app)·swift
// Canvas/FaceReference.swift (trimmed)
/// Runs face tracking only while it is on screen, and hands the face a new
/// skin a few times a second when the picture has changed.
final class FaceReferenceView: ARSCNView, ARSCNViewDelegate {
    weak var model: CanvasModel?
    /// The face's material. SceneKit reads it on its render thread, which
    /// makes the face node; only the main thread changes its picture.
    nonisolated(unsafe) private let skin = SCNMaterial()
    // ...
    override func didMoveToWindow() {
        super.didMoveToWindow()
        if window != nil, FacePaint.isSupported {
            let configuration = ARFaceTrackingConfiguration()
            configuration.isLightEstimationEnabled = true
            session.run(configuration, options: [.resetTracking, .removeExistingAnchors])
            timer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
                MainActor.assumeIsolated { self?.refreshSkin() }
            }
            refreshSkin()
        } else {
            timer?.invalidate()
            timer = nil
            session.pause()
        }
    }
    // ...
    nonisolated func renderer(_ renderer: any SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
        // Called on SceneKit's render thread.
        guard anchor is ARFaceAnchor, let device = renderer.device,
              let geometry = ARSCNFaceGeometry(device: device) else { return nil }
        geometry.firstMaterial = skin
        return SCNNode(geometry: geometry)
    }

    nonisolated func renderer(_ renderer: any SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
        guard let face = anchor as? ARFaceAnchor, let geometry = node.geometry as? ARSCNFaceGeometry else { return }
        geometry.update(from: face.geometry)
    }
}

// App/Studio.swift (trimmed)
        // Memory pressure trims history first, oldest steps first, and never
        // touches layers (architecture: Failure, Recovery, and Safety).
        memoryWarnings = NotificationCenter.default.addObserver(
            forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main
        ) { [weak self] _ in
            MainActor.assumeIsolated { self?.canvas?.handleMemoryWarning() }
        }

// Canvas/CanvasMetalView.swift (verbatim)
    /// Restarts the QuickShape clock: if the pen rests this long, the stroke snaps.
    private func armQuickShape() {
        restTimer?.invalidate()
        guard model?.studio.settings.quickShape == true else { return }
        restTimer = Timer.scheduledTimer(withTimeInterval: model?.studio.settings.gestures.quickShapeDelay ?? 0.55,
                                         repeats: false) { [weak self] _ in
            MainActor.assumeIsolated {
                guard let self, self.activeTouch != nil, self.model?.mode == .paint else { return }
                self.model?.holdStroke()
            }
        }
    }
A minimal sketch: freeze on the main actor, read in a detached task, copy before the next write (compiled and run with swiftc -swift-version 6)·swift
// A minimal sketch: a non-Sendable buffer crosses threads only frozen,
// under an @unchecked Sendable wrapper whose promise is "never written again".
// swiftc -swift-version 6 frozen.swift -o frozen && ./frozen
final class Buffer {
    let pixels: UnsafeMutablePointer<UInt16>
    private(set) var isFrozen = false
    init(count: Int) {
        pixels = .allocate(capacity: count)
        pixels.initialize(repeating: 0, count: count)
    }
    deinit { pixels.deallocate() }
    func freeze() -> Buffer { isFrozen = true; return self }
}

struct Frozen: @unchecked Sendable {        // the promise lives here, not in a lock
    let buffer: Buffer
    init(_ buffer: Buffer) { self.buffer = buffer.freeze() }
}

@MainActor
final class Canvas {                         // the thread that owns the artwork
    var live = Buffer(count: 16)

    func paint(_ value: UInt16, at index: Int) {
        if live.isFrozen {                   // copy-on-write: never write a frozen buffer
            let copy = Buffer(count: 16)
            copy.pixels.update(from: live.pixels, count: 16)
            live = copy
        }
        live.pixels[index] = value
    }

    func archive() -> Frozen { Frozen(live) }   // cheap: a flag, not a copy
}

func encode(_ archive: Frozen) async -> Int {
    await Task.detached {                        // off the main actor
        (0..<16).reduce(0) { $0 + Int(archive.buffer.pixels[$1]) }
    }.value
}

let canvas = Canvas()
canvas.paint(7, at: 3)
let archive = canvas.archive()
canvas.paint(9, at: 4)                       // lands in a copy; the archive never sees it
print("archive sum:", await encode(archive), "| live sum:", Int(canvas.live.pixels[3]) + Int(canvas.live.pixels[4]))
// Output:
//   archive sum: 7 | live sum: 16
//
// Delete "@unchecked Sendable" from Frozen and Swift 6 refuses the detached task:
//   error: passing closure as a 'sending' parameter risks causing data races
//   between code in the current isolation context and concurrent execution
//   of the closure

External links

Exercise

Search your Swift 6 project for every @unchecked Sendable, nonisolated(unsafe) and MainActor.assumeIsolated. For each one, write two sentences in a comment beside it: what it asserts, and the one change elsewhere in the code that would make the assertion false. Any site where you cannot write the second sentence is the site to rework first.
Hint
The second sentence is usually about a different file than the annotation: who else can reach the pointer, who schedules the timer, which queue the observer names. If the answer is 'anyone with a reference', the annotation is guarding nothing, and a real copy, an actor, or a Sendable value type is the fix.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.