Swift Concurrency skill for Codex
Swift 6 strict concurrency without unchecked escapes. Codex migrates Swift code to strict concurrency one target at a time, choosing MainActor, actors, Sendable values or lock-protected types by rule rather than sprinkling @unchecked Sendable. It handles Swift 6.2 default isolation and @concurrent, accounts for actor reentrancy, gives every Task an owner that cancels it, bridges callbacks with continuations resumed exactly once, and confirms with Thread Sanitizer.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- swift-concurrency Installs to ~/.agents/skills/swift-concurrency/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install swift-concurrency The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project swift-concurrency Installs to .agents/skills/swift-concurrency/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/swift-concurrency.zipunzip -o swift-concurrency.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) swift-concurrency PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $swift-concurrency, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes and migrates Swift concurrency code - async/await, structured task groups, actors and MainActor isolation, Sendable checking, cancellation, AsyncStream and continuations for callback APIs - and fixes Swift 6 strict concurrency errors such as data races on non-Sendable values and main actor-isolated access from nonisolated contexts, including Swift 6.2 default MainActor isolation and @concurrent. Use when enabling the Swift 6 language mode, when Xcode shows Sendable or actor isolation errors, when bridging completion handlers to async, or when a Task leaks or ignores cancellation.
Use it when
- Enabling strict concurrency checking or the Swift 6 language mode
- Xcode reports Sendable, data race or actor isolation diagnostics
- Converting completion handlers or delegates to async/await
- Tasks keep running after a screen closes or ignore cancellation
Use something else when
- The issue is SwiftUI view structure or state ownership: use swiftui-views
- You need Package.swift targets, products or plugins set up: use swift-package-manager
How to check the result
The target builds in Swift 6 mode, swift test --sanitize=thread is clean across repeated runs, and every @unchecked Sendable or nonisolated(unsafe) in the diff has a written justification.
The mistake it prevents
Silencing Sendable errors with @unchecked Sendable or nonisolated(unsafe) on types that have no lock, which hides exactly the data races Swift 6 was reporting.
Done well means: the module builds in the Swift 6 language mode (or with complete strict
checking) without @unchecked Sendable or nonisolated(unsafe) added just to silence
errors, UI state is isolated to @MainActor, shared mutable state lives in an actor or a
lock-protected type with a stated reason, and every unstructured Task has an owner that
cancels it.
Find the current settings first
grep -n 'swift-tools-version\|swiftLanguageMode\|swiftLanguageModes\|enableUpcomingFeature\|defaultIsolation' Package.swift
grep -n 'SWIFT_VERSION\|SWIFT_STRICT_CONCURRENCY\|SWIFT_DEFAULT_ACTOR_ISOLATION\|SWIFT_APPROACHABLE_CONCURRENCY' *.xcodeproj/project.pbxproj
swift --version
Read AGENTS.md for the minimum deployment target: Mutex (Synchronization module) needs
iOS 18 / macOS 15; actors and async/await back-deploy to iOS 13.
Migration path
- [ ] 1. Swift 5 mode + strict concurrency "targeted", fix warnings module by module
- [ ] 2. Strict concurrency "complete"; fix warnings starting from leaf modules
- [ ] 3. Decide default isolation: app/UI targets -> MainActor default (Swift 6.2+); libraries -> nonisolated
- [ ] 4. Switch the module to Swift 6 language mode; warnings become errors
- [ ] 5. Run tests with Thread Sanitizer; remove any remaining @unchecked that lacks a proof
SwiftPM settings:
// swift-tools-version: 6.2
.target(
name: "App",
swiftSettings: [
.defaultIsolation(MainActor.self), // app/UI code only
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
]
)
// Swift 5 mode during migration: .swiftLanguageMode(.v5) plus
// .enableUpcomingFeature("StrictConcurrency")
Xcode 26: Build Settings “Default Actor Isolation” = MainActor, “Approachable Concurrency” = Yes, “Swift Language Version” = 6. Migrate one target at a time.
Isolation decisions
| Kind of code | Isolation |
|---|---|
| Views, view models, anything touching UIKit/AppKit/SwiftUI state | @MainActor (or the target’s default) |
| Shared mutable state accessed from many tasks (caches, session store) | actor |
| Hot, tiny critical sections, or synchronous API required | final class + Mutex (iOS 18+) or OSAllocatedUnfairLock, marked Sendable |
| Pure data passed between tasks | struct/enum with Sendable members (implicitly Sendable when internal) |
| CPU-heavy work that must leave the main actor | @concurrent async function (Swift 6.2+) or nonisolated async on older compilers |
| Immutable reference type | final class with only let Sendable properties, declared Sendable |
actor ImageCache {
private var store: [URL: Image] = [:]
private var inFlight: [URL: Task<Image, Error>] = [:]
func image(for url: URL, loader: @Sendable @escaping (URL) async throws -> Image) async throws -> Image {
if let cached = store[url] { return cached }
if let task = inFlight[url] { return try await task.value } // dedupe across reentrancy
let task = Task { try await loader(url) }
inFlight[url] = task
defer { inFlight[url] = nil }
let image = try await task.value
store[url] = image
return image
}
}
Actor reentrancy: state can change across every await inside an actor method. Re-check
assumptions after each suspension and record in-flight work (as above) instead of
assuming “nothing else ran”.
Fixing common diagnostics
| Diagnostic (abridged) | Fix |
|---|---|
| Sending ‘x’ risks causing data races | Make the type Sendable, pass a copy, or mark the parameter sending when ownership transfers |
| Main actor-isolated property ‘x’ can not be referenced from a nonisolated context | Mark the caller @MainActor, await MainActor.run { }, or make the property nonisolated if it is immutable |
| Type ‘X’ does not conform to the ‘Sendable’ protocol | Value type with Sendable fields, final class with lets, or an actor; @unchecked Sendable only with an internal lock and a comment |
| Static property ‘shared’ is not concurrency-safe | static let of a Sendable type, isolate it to @MainActor, or make the type an actor |
Capture of ‘self’ with non-sendable type in a @Sendable closure | Isolate the class to an actor, or capture the specific Sendable values instead of self |
| Call to main actor-isolated initializer in a synchronous nonisolated context | Make the calling function @MainActor or async |
| Warnings from a dependency that is not yet audited | @preconcurrency import ThatModule (temporary; note it in the report) |
nonisolated(unsafe) and @unchecked Sendable move race detection from the compiler to
you. Each use needs a comment naming the lock or invariant that makes it safe.
Tasks and cancellation
| Need | Use |
|---|---|
| Fixed parallel work with results | async let a = f(); async let b = g(); try await (a, b) |
| Dynamic number of children | withThrowingTaskGroup(of:); withDiscardingTaskGroup for fire-and-forget children |
| Start async work from sync UI code | Task { } stored in a property and cancelled in deinit/onDisappear; SwiftUI .task { } cancels automatically |
| Work that must not inherit actor or priority | Task.detached (rarely needed) |
func thumbnails(for urls: [URL]) async throws -> [URL: Image] {
try await withThrowingTaskGroup(of: (URL, Image).self) { group in
for url in urls.prefix(100) {
group.addTask { (url, try await render(url)) }
}
var result: [URL: Image] = [:]
for try await (url, image) in group { result[url] = image }
return result
}
}
- Cancellation is cooperative: loops call
try Task.checkCancellation()or checkTask.isCancelled. URLSession async APIs already respect it. withTaskCancellationHandlercancels underlying non-async work (a callback request).- Limit group width for large inputs: add tasks up to N, then add one more each time one finishes.
Bridging callback APIs
func currentLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { continuation in
locator.requestOnce { result in continuation.resume(with: result) } // exactly once
}
}
func events() -> AsyncStream<Event> {
let (stream, continuation) = AsyncStream.makeStream(of: Event.self, bufferingPolicy: .bufferingNewest(100))
let token = bus.subscribe { continuation.yield($0) }
continuation.onTermination = { _ in bus.unsubscribe(token) }
return stream
}
Resume a continuation exactly once on every path, including errors and cancellation; the checked variant traps on double resume and logs a leak if never resumed.
Gotchas
Task { }inherits the current actor; inside a@MainActorview model it runs on the main actor. Heavy synchronous work inside it still blocks the UI; move it to an@concurrentor nonisolated function.- With
NonisolatedNonsendingByDefault(Swift 6.2 approachable concurrency), nonisolated async functions run on the caller’s actor instead of the global executor. Code that relied on hopping off the main actor needs@concurrent. - With default MainActor isolation, every unannotated type and global in the target is
main-actor isolated. Types used from
@Sendableclosures (onTermination, callbacks, background services) need an explicitnonisolatedon the declaration. DispatchQueue.main.asyncandDispatchSemaphore.wait()inside async code mix models and can deadlock; never block a cooperative thread waiting on async work.MainActor.assumeIsolatedcrashes if the assumption is wrong; use it only in callbacks documented to arrive on the main thread.- Global and static mutable
vars are errors in Swift 6; most should becomelet, actor-isolated, or injected.
Verification
swift build -Xswiftc -strict-concurrency=complete # during migration
swift test --sanitize=thread
xcodebuild test -scheme App -destination 'platform=iOS Simulator,name=iPhone 17' -enableThreadSanitizer YES
Grep the diff for @unchecked Sendable, nonisolated(unsafe) and @preconcurrency; each
must have a justification. Re-run tests several times; TSan reports are real bugs.
Report template
**Modules migrated:** Networking (Swift 6), Models (Swift 6), App (Swift 6, default MainActor)
**Diagnostics fixed:** 64 (41 Sendable, 17 isolation, 6 global state)
**Escape hatches:** 1 @unchecked Sendable (TokenStore, guarded by Mutex), 1 @preconcurrency import LegacySDK
**Tasks:** 3 unowned Task {} now stored and cancelled on disappear
**Checks:** swift test --sanitize=thread clean x3; app smoke test on simulator What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Swift Concurrency"
short_description: "Swift 6 strict concurrency without unchecked escapes"
default_prompt: "$swift-concurrency Turn on Swift 6 language mode for our networking module and fix the errors properly" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.