Where Does Nothing Run?
An appendix to Rejected
Here is a line of Swift that crashes on macOS Sequoia:
completionHandler: { _, _ in }
This is a block of code that does absolutely nothing. No computation, no data, no side effects. It receives two arguments, ignores both, and returns void. The developer equivalent of a polite nod. On macOS Ventura, it ran without incident. On Sequoia, it kills the app.
The programming term for this construct is a closure: a block of code you write now and hand to someone else to run later. Think of it as a note you slip into an envelope. The recipient opens the envelope, reads the instructions, and follows them. This particular note is blank.
The closing line of the post that described this bug was: “An empty closure is not free. In Swift 6, even doing nothing has a type, and that type has opinions about where it runs.”
This article unpacks that sentence.
The fundamental question
The crash happened because of where the blank note was read, not what it said.
An app can do multiple things simultaneously: download a file, respond to a tap, animate a spinner. Each of these jobs runs on a thread, a worker that executes instructions in sequence. Your app has several threads, each handling a different task. One of them is special: the main thread. The main thread owns the screen. Every pixel change, every button press, every animation frame goes through it. Update the screen from any other thread and something breaks. Sometimes a flicker. Sometimes a crash. Sometimes corruption that shows up three screens later.
When your code asks an API to do something slow (fetch data from a server, launch another application), the API does that work on its own thread. When the work finishes, the API needs to tell you. It does this by calling your closure, the note you handed over earlier. This is a completion handler: the API calls you back when it is done.
But which thread does the API call your closure on? Its thread? Your thread? The main thread? This is where the crash lives.
Three questions frame what follows:
- Which thread does the API run your closure on?
- Does the closure carry a label saying where it belongs?
- What happens when the label and the reality disagree?
The investigation
The closure does nothing. It captures nothing. It should be invisible to the type system. It is not.
Here is the code:
@MainActor
class DiagnosticsViewModel {
func openApp() {
NSWorkspace.shared.openApplication(
at: url,
configuration: config,
completionHandler: { _, _ in } // ← the bug
)
}
}
Four layers, each seemingly harmless, combine to produce the crash. Like a chain of custody where every handler followed protocol and the evidence still went missing.
Layer 1: the annotation. DiagnosticsViewModel is @MainActor. This means all its methods and properties are isolated to the main actor (effectively, the main thread). This is correct: the view model drives UI, so it belongs on the main actor. Nothing wrong here.
Layer 2: the inference. The closure does nothing. It captures nothing. It should be free to run anywhere. But Swift has a rule:1 if the API’s parameter type for the closure does not explicitly opt out of thread affinity, the closure inherits the isolation of its enclosing context. Two markers opt out: @Sendable (on the closure) and sending (on the parameter that receives it), both of which tell the compiler “this value may move between threads.” The completionHandler parameter, bridged from Objective-C, carries neither marker, because those concepts did not exist when the API was written. So {'{'{'}'} _, _ in {'}'} becomes @MainActor {'{'{'}'} _, _ in {'}'}.
The compiler infers from position, not content, to avoid a treacherous cliff where adding one line silently changes isolation.2 The empty closure pays for the sins of its non-empty siblings.
Layer 3: the API. NSWorkspace.openApplication(at:configuration:completionHandler:) is an Objective-C API bridged to Swift. The header says the completion handler is “called on a concurrent queue,” but does not name which one or guarantee any relationship to the caller’s queue.
Compare this with the older recycleURLs:completionHandler:, whose header explicitly states “called on the same dispatch queue that was used for the recycleURLs: call.” That is a contract the type system could encode. The openApplication method offers no equivalent. “A concurrent queue” tells the developer it will not be the main queue, but gives the Swift type system nothing to work with: no annotation maps “concurrent queue” to nonisolated or @Sendable.
Layer 4: the enforcement. The Swift concurrency runtime shipped with macOS Sequoia began enforcing actor isolation checks at runtime. SE-0424 extended this mechanism to custom executors via checkIsolated, but the core assertion, dispatch_assert_queue_fail, comes from Grand Central Dispatch. When a closure annotated @MainActor is invoked on a queue that is not the main queue, that assertion fires. On earlier OS versions, the runtime did not trigger this check on the relevant code paths, so the mismatch went undetected.
Four layers, each correct in isolation, producing a crash when combined. The bug lives in the gap between the Swift type system and the Objective-C API contract. The gap exists not because the information is unknowable, but because the Objective-C API predates the type vocabulary needed to express it. Apple has been annotating legacy headers with @Sendable and sending since Xcode 14, but thousands of completion handler parameters remain unannotated.
The design tradeoff
Swift 6’s inference is not a mistake. It exists because the alternative is worse.
Consider a closure that does access @MainActor state:
@MainActor
class ViewModel {
var items: [Item] = []
func loadItems() {
api.fetch { result in
self.items = result // accesses @MainActor state
}
}
}
Without inference, this closure would run on whatever thread the API chooses, mutating @MainActor state from a background thread. That is a data race. Swift 6’s inference prevents this at compile time, provided the API declares its threading contract in the type system. When the API is properly annotated, the compiler catches the mismatch. When it is not, the mismatch survives to runtime.
The inference catches the common case (closures that use their enclosing state) at the cost of the uncommon case (closures that use nothing). The empty closure is collateral damage.
If the completion handler parameter were marked @Sendable or sending, Swift would infer the closure as nonisolated instead.3 But the Objective-C bridging layer adds neither annotation, because those concepts did not exist when the API was written. The inference rule is correct. The input to the rule is incomplete.
This is the deeper issue: the Objective-C boundary. Swift’s type system assumes that API contracts are expressed in types. Objective-C APIs predate this assumption. The Swift compiler sees a completion handler, infers isolation, and has no way to know that the API will violate that isolation at runtime. Type-level guarantees are only as strong as the weakest boundary they cross.
macOS Sequoia chose to make the divergence loud rather than silent. That is the right call: silent divergence leads to data races; loud divergence leads to a crash log you can read.
The fix
The fix in Renala was to remove the completion handler entirely:
// Before: empty closure inherits @MainActor, crashes on background queue
NSWorkspace.shared.openApplication(at: bundleURL, configuration: config) { _, _ in }
// After: no closure, no inference, no crash
NSWorkspace.shared.openApplication(at: bundleURL, configuration: config)
The API does not require a completion handler. Passing one was defensive coding, an acknowledgment that the call might fail. But an empty handler does not handle anything. It was ceremony, and in Swift 6, ceremony has a type.
When the closure must exist, two other options break the inference chain. Marking the closure @Sendable tells the compiler it may cross isolation boundaries, which opts out of @MainActor inheritance. Wrapping the call in a Task.detached block creates a nonisolated context where inference starts from scratch. Both are explicit statements: the developer tells the compiler “I know where this runs.”
The lineup
That is how the crash works. But is it inevitable? Do other languages have the same problem, or does Swift 6 stand alone? The answer reveals what makes this design unique. The interactive below lets you dispatch the same empty closure in each language and watch what happens.
JavaScript: the escape
JavaScript has one thread. The event loop processes callbacks sequentially. There is no “wrong thread” because there is no other thread.
fetch('/api/data').then(() => {
// This runs on the only thread there is.
// An empty callback: () => {}
// Cost: zero. Risk: zero.
});
An empty callback in JavaScript is genuinely free. No type annotation, no thread affinity, no runtime assertion. Web Workers exist, but they communicate through message passing, not shared closures. The problem this article investigates cannot arise.
C# / .NET: the invisible hand
C# popularized the async/await pattern that most languages later adopted, and with it, a mechanism called SynchronizationContext. This is the invisible hand that decides where your code resumes after an await.
// In a WPF or WinForms UI method:
async Task LoadDataAsync()
{
var data = await FetchFromNetwork(); // suspends here
label.Text = data.Title; // resumes on UI thread
}
When you await inside a UI method, the framework captures the current SynchronizationContext (which, on the UI thread, points back to the UI thread). When the awaited task completes, the continuation is posted back to that context. C# found the edge of this approach early: the classic .NET deadlock happens when synchronous code blocks the UI thread while an await continuation needs that same thread to resume.4
But here is the critical point: plain lambdas carry no threading information. Only await continuations capture context. A bare lambda passed to an API is just a function reference with no opinion about where it runs.5
// Empty lambda: zero cost, zero thread affinity.
DoSomethingAsync(callback: () => { });
Java: honest ignorance
Java has rich concurrency libraries for managing threads and shared state. But none of that machinery touches the lambda’s type. The type system knows nothing about threads, and it does not pretend to.
// Consumer<T> is a function type. Period.
// No thread annotation. No context capture.
executor.submit(() -> {
// Runs on whatever thread the executor provides.
});
// On Android, threading is explicit:
runOnUiThread(() -> {
textView.setText("Updated");
});
The compiler helps with nothing regarding threads. But it also misleads about nothing. No inference to get wrong, no runtime assertion to violate. An empty lambda is free. The cost falls on the developer, who must dispatch to the right thread manually.
Kotlin: the middle path
Kotlin has coroutines: functions that can suspend, yield control, and resume later. Think of it as automatic resource management for concurrent tasks: when a parent task ends, all its children are cancelled and cleaned up, much like try-with-resources in Java. Dispatchers are named thread pools that decide where coroutine code runs.
// Explicit dispatcher: you choose where this runs.
viewModelScope.launch(Dispatchers.Main) {
val data = withContext(Dispatchers.IO) {
fetchFromNetwork()
}
// Back on Main after withContext returns.
textView.text = data.title
}
The critical design difference from Swift 6: plain lambdas in Kotlin do not inherit the dispatcher from their enclosing scope. Inside a coroutine, launch {'{'{'}'}{'}'} does inherit its parent’s dispatcher via structured concurrency, but a lambda passed to a non-coroutine API is just a lambda. It carries no Dispatchers.Main annotation, no implicit thread affinity.
Kotlin and Swift 6 both pursue structured concurrency, but they aim at different problems. Swift 6 uses it to eliminate data races at compile time. Kotlin uses it to manage task lifetimes and cancellation. Kotlin trusts the developer to choose the right thread. Swift 6 trusts the compiler to infer it.
The spectrum
Laid out side by side, these five languages form a spectrum from “no opinion” to “strong opinion, enforced”:
| Language | Closure type carries thread info? | Who decides where it runs? | Empty closure cost | Failure mode |
|---|---|---|---|---|
| JavaScript | N/A (one thread) | Event loop | Free | None possible |
| Java | No | Caller (explicit) | Free | Wrong-thread bugs at runtime, no compiler help |
| C# / .NET | Only at await | SynchronizationContext (implicit at await) | Free | Deadlock if blocking on async; wrong-thread if context is bypassed |
| Kotlin | No (dispatchers are explicit) | Developer via withContext | Free | Wrong-thread bugs if dispatcher is mismatched; explicit dispatch makes them traceable |
| Swift 6 | Yes (inferred @MainActor) | Compiler inference + runtime assertion | Not free | Runtime crash if inferred isolation disagrees with caller |
The spectrum runs from left to right: more safety guarantees, more surface area for the type system to disagree with reality.
The cost of doing nothing
In every other language surveyed, an empty closure is free. In Swift 6, it has a type, that type has opinions, and those opinions are enforced.
An empty closure is the simplest possible callable: no state, no side effects, no computation. It is the “hello world” of closures. And yet, in one specific language, on one specific OS version, passed to one specific API, it crashes. Not because the closure is wrong, but because the type is wrong, and the type was never written by a human.
The final irony: a closure that captures nothing has nothing to race on. It is, by definition, safe to run on any thread. The type system enforces thread affinity on the one closure in the program that least needs it.
References
- Swift Evolution: SE-0316 — Global Actors (introduces
@MainActorand global actor inference rules, including closure isolation inheritance) - Swift Evolution: SE-0461 — Async Function Isolation (refines how nonisolated async functions inherit caller isolation)
- Apple: MainActor documentation
- Microsoft: SynchronizationContext Class
- Kotlin: Coroutine context and dispatchers
- Stephen Cleary: Async and Await (the definitive .NET async primer)
Footnotes
-
The closure isolation inference rule comes from SE-0316, which defines global actors and their inference rules. SE-0461 further refines how nonisolated async functions inherit isolation, and its discussion provides additional context on closure isolation semantics. ↩
-
The compiler could notice that this closure captures nothing and skip the inference. It deliberately does not. There are two approaches: decide by position (inside a
@MainActorclass? then the closure is@MainActor) or by content (does it touch@MainActorstate? no? leave it alone). Swift chose position. The content-based approach sounds smarter but creates a treacherous cliff: add one line that touchesself, and the closure silently changes isolation. The position-based rule is predictable even when conservative. This is a design choice, not a compiler limitation. ↩ -
@Sendablemarks a closure as safe to send across concurrency domains, requiring all captured values to conform to theSendableprotocol. If the closure captures a mutable array, that array must beSendable. If it captures nothing, the question is moot. The newersendingannotation does the same for parameters. Either annotation would cause Swift to infernonisolated, because a closure that can cross isolation boundaries must not be tied to any particular actor. ↩ -
The classic example: a button handler calls
LoadDataAsync().Result, blocking the UI thread. Theawaitcontinuation insideLoadDataAsyncneeds the UI thread to resume, but.Resultholds it hostage. Neither can proceed. This is a WPF/WinForms pattern; ASP.NET Core has noSynchronizationContextby default, so the deadlock does not arise there. ↩ -
ConfigureAwait(false)tells the awaiter not to marshal the continuation back to the original context. It is a performance optimization: library code has no reason to return to the caller’s UI thread, so it declines the hop. Code after aConfigureAwait(false)loses the safety net and must dispatch to the UI thread manually. ↩
