Rejected

“Two rejection reasons. One was my fault. One was almost my fault.”

The email arrived a few days after submission. No drama in the subject line. App Store Connect showed two guideline violations: 2.1(a) and 2.1(b). A crash, and a UI that looked broken.

Apple attaches a crash log to the rejection. I opened it.

Rejection 1: Guideline 2.1(a), the Crash

A crash log reads top-down: exception type, crashed thread, then stack frames with the closest to the crash first. The frame that matters is the first one past the system frames and into your code.

The log named dispatch_assert_queue_fail: the process tried to run code on the wrong dispatch queue, and a runtime assertion killed it.1 The thread: com.apple.launchservices.open-queue. The frame directly above it: a closure inside DiagnosticsViewModel.openApp().

Exception Type:  EXC_BREAKPOINT (SIGTRAP)
Exception Codes: 0x0000000000000001, 0x00000001xxxxxxxx
Crashed Thread:  com.apple.launchservices.open-queue

Thread 7 Crashed:: com.apple.launchservices.open-queue
0   libdispatch.dylib       dispatch_assert_queue_fail + 120
1   AppKit                  -[NSWorkspace _callCompletionHandler:...] + 88
2   Renala                  closure #1 in DiagnosticsViewModel.openApp() + 64
    // ^^^ This is the empty { _, _ in } closure.
    //     Swift 6 marked it @MainActor.
    //     It's running on the launch services queue.
    //     dispatch_assert_queue_fail fires.

The call site looked like this:

NSWorkspace.shared.openApplication(
    at: url,
    configuration: config,
    completionHandler: { _, _ in }
)

An empty closure, passed because the API has a completion handler parameter. The developer equivalent of nodding politely. It does nothing. So how did it crash?

DiagnosticsViewModel is @MainActor. In Swift 6, a closure that is not explicitly marked @Sendable (safe to pass across concurrency boundaries) or nonisolated (not bound to any specific actor) inherits the actor isolation of its enclosing context.2 So {'{'{'}'} _, _ in {'}'} became @MainActor {'{'{'}'} _, _ in {'}'}.

But NSWorkspace.openApplication(at:configuration:completionHandler:) calls the completion handler on com.apple.launchservices.open-queue, not on the main actor. Starting with macOS Sequoia, AppKit began enforcing that expectation with dispatch_assert_queue.3 If the closure is marked @MainActor but runs on a different queue, the assertion fires and the process crashes.

macOS Sequoia was not installed on my development machine. Of course it wasn’t.

The fix is one line removed. If the completion handler does nothing, remove it entirely:

NSWorkspace.shared.openApplication(at: url, configuration: config)

No closure, no type annotation, no assertion violation.4

The tension is real. Swift 6 inferred @MainActor on the closure, following its own rules correctly. But the API calls the closure on a different queue. Before macOS Sequoia, this mismatch was invisible. After, it crashes.

The compiler cannot catch it because the completionHandler parameter in AppKit’s header lacks the annotations that would surface the conflict at compile time.5 An empty closure is not free. In Swift 6, even doing nothing has a type, and that type has opinions about where it runs. For a deeper look at how this compares across languages, see Where Does Nothing Run?.

Rejection 2: Guideline 2.1(b), the Empty Tip Jar

The second rejection was the more interesting one. The tip jar appeared blank during review. No products, no error. Just an empty sheet where the tip options should be.

The root cause:

// What you expect:
let products = try await Product.products(for: ["tip_small", "tip_medium", "tip_large"])
// products: [Product, Product, Product]

// What the reviewer gets:
// products: []  (no error, no exception, just empty)

Product.products(for:) returns an empty array when the Paid Apps Agreement in App Store Connect is not active.6 The agreement requires banking information, tax forms, and Digital Services Act compliance documentation to be completed first. Without an active agreement, Apple’s sandbox does not serve paid products regardless of their individual configuration. There is no error, no thrown exception, just an empty array, indistinguishable from “you made a typo in your product identifier.”7

The local StoreKit configuration in Xcode masked the problem entirely: every development and testing session served products from a local JSON file, bypassing Apple’s servers. The tip jar always worked on the developer’s machine.

An empty sheet with no explanation looks like a broken app. The reviewer sees a UI with nothing in it. They flag it under 2.1(b).

The fix: detect when the products array is empty and show a message instead of nothing.

if products.isEmpty {
    Text("Tips are not available right now.")
        .foregroundStyle(.secondary)
} else {
    // tip buttons
}

An empty sheet looks broken. A message looks intentional. The difference between a bug and a feature is sometimes just a label.

Both rejections, their root causes, and their fixes:

The Resubmission

Both fixes together. The crash fix removed one line. The tip jar fix added a conditional text view. Neither change touched any other functionality.

Bump build number, tag, let the pipeline run. The second review was faster than the first.

On Reading Crash Logs

The structure is always the same: exception type at the top (EXC_BREAKPOINT means the runtime hit a programmatic assertion, not an actual debugger breakpoint8), crashed thread, then stack frames closest to the crash first.

In this case, three data points told the whole story. The frame named the exact closure. The queue name was in the thread label. The assertion name described precisely what went wrong. From there, the cause was clear.

Apple does not include the Swift type annotation in the crash log. That requires reading the source. But the log points to the right location. Reading source from a specific frame is faster than general debugging.

The crash was on a macOS version not installed locally. The fix was a one-line deletion. The cost of not catching it earlier: 6 reviews across 4 versions, a few weeks of review time.

That cost is what gets you to set up testing on the full range of supported OS versions, or at minimum to spin up a VM before resubmitting. The crash was obvious once the log was in hand. The gap was not having the environment to reproduce it before submission.

References

Footnotes

  1. A dispatch queue is GCD’s abstraction for scheduling work: you submit closures to a queue, and GCD decides which thread runs them. The main queue is tied to the main thread (where UI work happens); other queues may run work on any available thread.

  2. The full rule is more nuanced. Under SE-0316, closures marked @Sendable or passed to a sending parameter are inferred as nonisolated, regardless of their enclosing context. But completion handlers in older Objective-C APIs like NSWorkspace often lack @Sendable annotations in the SDK headers, so the closure inherits its enclosing actor’s isolation instead.

  3. dispatch_assert_queue itself has existed since macOS 10.12. What changed in Sequoia is that AppKit’s internal _callCompletionHandler: began using it to enforce queue expectations, making previously invisible mismatches fatal.

  4. For cases where you need a non-empty completion handler, alternatives include marking the closure @Sendable explicitly to opt out of actor isolation inheritance, or using @preconcurrency import AppKit to suppress legacy annotation mismatches during migration.

  5. If the completionHandler parameter were annotated @Sendable (without @MainActor), the compiler would flag a @MainActor-isolated closure being passed where a non-isolated @Sendable closure is expected. The gap is a missing framework annotation, not a fundamental limitation of the type system.

  6. The Paid Apps Agreement is an account-level prerequisite in App Store Connect, separate from each IAP product’s review status. It requires banking, tax (W-8BEN-E), and DSA compliance setup. The agreement can take days to process after submission. Until it reaches “Active” status, Product.products(for:) returns an empty array for all paid products in Apple’s sandbox.

  7. The older SKProductsRequest API (StoreKit 1) provides slightly better diagnostics here: it populates an invalidProductIdentifiers array for IDs it cannot resolve. The StoreKit 2 Product.products(for:) API returns only the empty array with no indication of why.

  8. The name is confusing. EXC_BREAKPOINT (SIGTRAP) in a crash log means the code executed a trap instruction, which is how the runtime signals a fatal assertion failure. It has nothing to do with a debugger breakpoint you might set in Xcode.