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_toandard_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, notSendable, 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 isCanvasModel, which is@MainActor. - The only parallel C is in Ariandel's pixel kernels, which spread loops across cores with
dispatch_apply_fand 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.
| Site | Annotation | Asserts | False if |
|---|---|---|---|
| libmypaint tile callbacks | none (nonisolated top-level functions, passed as @convention(c)) | runs inside a call Swift made, on its thread | C ever calls back later, or from another thread |
FrozenTile | @unchecked Sendable | a frozen buffer is never written | pixels are written without writableTile |
| Face reference delegates | nonisolated, nonisolated(unsafe) | the render thread only uses the material | the methods reach other state |
| Timers, the memory observer | MainActor.assumeIsolated | delivered on the main thread | scheduled off main, or queue: nil |
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.