Rejeté

« Deux motifs de rejet. L’un était de ma faute. L’autre, presque. »

L’e-mail est arrivé quelques jours après la soumission, rien d’alarmant dans l’objet. App Store Connect affichait deux violations : 2.1(a) et 2.1(b). Un plantage, et une interface qui semblait cassée.

Apple joint un journal de plantage au rejet : je l’ai ouvert.

Rejet nᵒ 1 : directive 2.1(a), le plantage

Un journal de plantage se lit de haut en bas : type d’exception, thread fautif, puis les frames de la plus proche du plantage vers le haut. Celle qui compte : la première après les frames système, là où commence votre code.

Le journal nommait dispatch_assert_queue_fail, une vérification à l’exécution qui tue le processus quand du code tourne sur la mauvaise file d’attente.1 Le thread : com.apple.launchservices.open-queue. La frame juste au-dessus : une fermeture dans 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.

Le code appelant ressemblait à ceci :

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

Une fermeture vide, passée parce que l’API attend un completionHandler : l’équivalent d’un hochement de tête poli. Elle ne fait rien. Alors comment a-t-elle planté ?

DiagnosticsViewModel est @MainActor. En Swift 6, une fermeture non marquée @Sendable (transmissible entre contextes concurrents) ni nonisolated (détachée de tout acteur) hérite de l’isolation de son contexte englobant.2 Donc {'{'{'}'}{'{'{'}'} _, _ in {'}'}{'}'} est devenu @MainActor {'{'{'}'}{'{'{'}'} _, _ in {'}'}{'}'}.

Sauf que NSWorkspace.openApplication(at:configuration:completionHandler:) appelle le completionHandler sur com.apple.launchservices.open-queue, pas sur l’acteur principal. À partir de macOS Sequoia, AppKit appelle dispatch_assert_queue en amont de ses completionHandlers.3 Si la fermeture est marquée @MainActor mais s’exécute sur une autre file d’attente, l’assertion échoue et le processus plante.

macOS Sequoia n’était pas installé sur ma machine de développement, évidemment.

Le correctif tient en une ligne supprimée. Si le completionHandler ne fait rien, on le retire :

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

Plus de fermeture, plus d’annotation de type, plus de violation d’assertion.4

La tension est réelle. Swift 6 a inféré @MainActor sur la fermeture, conformément à ses propres règles. Mais l’API exécute la fermeture sur une autre file d’attente. Avant macOS Sequoia, cette incohérence passait inaperçue ; après, ça plante.

Le compilateur ne peut rien y faire : le paramètre completionHandler dans l’en-tête d’AppKit n’a pas les annotations qui feraient remonter le conflit à la compilation.5

La leçon : une fermeture vide n’est pas gratuite ; en Swift 6, même ne rien faire a un type, et ce type a son avis sur l’endroit où il s’exécute. Pour une analyse approfondie de ce mécanisme à travers cinq langages, voir Où s’exécute le néant ?.

Rejet nᵒ 2 : directive 2.1(b), la boîte à pourboires vide

Le second rejet était le plus instructif. La boîte à pourboires apparaissait vide pendant la validation. Ni produits, ni erreur : juste un panneau vide là où les options de pourboire auraient dû s’afficher.

La cause :

// Ce qu'on attend :
let products = try await Product.products(for: ["tip_small", "tip_medium", "tip_large"])
// products: [Product, Product, Product]

// Ce que le réviseur obtient :
// products: []  (pas d'erreur, pas d'exception, juste vide)

Product.products(for:) renvoie un tableau vide quand le contrat Applications payantes d’App Store Connect n’est pas actif.6 Le contrat exige des coordonnées bancaires, des formulaires fiscaux et une déclaration de conformité au Digital Services Act européen. Sans contrat actif, le bac à sable d’Apple ne sert aucun produit payant, quel que soit leur statut individuel. Aucune erreur, aucune exception levée, juste un tableau vide, indiscernable d’une faute de frappe dans l’identifiant du produit.7

La configuration StoreKit locale dans Xcode masquait entièrement le problème : chaque session de développement et de test servait les produits depuis un fichier JSON local, sans jamais passer par les serveurs d’Apple. La boîte à pourboires fonctionnait toujours sur ma machine.

Un panneau vide sans explication a tout l’air d’un bug. Le réviseur voit une interface qui semble cassée et la signale sous 2.1(b).

Le correctif : détecter un tableau de produits vide et afficher un message plutôt que rien.

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

Un panneau vide a l’air cassé ; un message a l’air voulu. Ce qui sépare un bug d’un comportement normal, c’est parfois juste un libellé.

Les deux rejets, leurs causes et leurs correctifs :

La resoumission

Les deux correctifs ensemble : celui du plantage a supprimé une ligne, celui de la boîte à pourboires a ajouté un affichage conditionnel. Aucun des deux n’a touché au reste du code.

Incrémenter le numéro de build, taguer, laisser le pipeline tourner : la seconde validation a été plus rapide que la première.

Lire un journal de plantage

La structure est toujours la même : le type d’exception en haut (EXC_BREAKPOINT signifie que le runtime a déclenché une assertion fatale, pas qu’un point d’arrêt a été posé dans Xcode8), le thread fautif, puis les frames de la plus proche du plantage vers le haut.

Ici, trois éléments racontaient toute l’histoire. La frame nommait la fermeture exacte ; le nom de la file d’attente figurait dans le libellé du thread ; le nom de l’assertion décrivait précisément ce qui avait déraillé. À partir de là, la cause était limpide.

Apple n’inclut pas l’annotation de type Swift dans le journal de plantage : pour ça, il faut lire le code source. Mais le journal pointe au bon endroit : remonter le source à partir d’une frame précise est plus rapide qu’un débogage à l’aveugle.

Le plantage se produisait sur une version de macOS absente de ma machine ; le correctif, une ligne supprimée. Le prix de ne pas l’avoir détecté plus tôt : 6 avis sur 4 versions, quelques semaines de délai supplémentaires.

Ce genre de coût pousse à tester sur toute la gamme de versions d’OS supportées, ou au minimum à lancer une VM avant de resoumettre. Le plantage était évident une fois le journal en main ; ce qui manquait, c’était de quoi le reproduire avant la soumission.

Références

Footnotes

  1. Une file d’attente (dispatch queue) est l’abstraction de GCD pour planifier du travail : on soumet des fermetures à une file, et GCD décide quel thread les exécute. La file principale est liée au thread principal (là où le travail d’interface se fait) ; les autres files peuvent exécuter le travail sur n’importe quel thread disponible.

  2. La règle complète est plus nuancée. Selon SE-0316, les fermetures marquées @Sendable ou passées à un paramètre sending sont inférées nonisolated, quel que soit leur contexte englobant. Mais les completionHandlers des anciennes API Objective-C comme NSWorkspace n’ont souvent pas d’annotation @Sendable dans les en-têtes du SDK : la fermeture hérite donc de l’isolation de l’acteur englobant.

  3. dispatch_assert_queue existe depuis macOS 10.12. Ce qui a changé avec Sequoia, c’est que la méthode interne _callCompletionHandler: d’AppKit a commencé à l’utiliser pour vérifier les attentes de file d’attente, rendant fatales des incohérences jusque-là invisibles.

  4. Si le completionHandler n’est pas vide, on peut marquer la fermeture @Sendable explicitement pour désactiver l’héritage d’isolation, ou utiliser @preconcurrency import AppKit pour supprimer les incohérences d’annotations héritées pendant la migration.

  5. Si le paramètre completionHandler était annoté @Sendable (sans @MainActor), le compilateur signalerait qu’une fermeture isolée @MainActor est passée là où une fermeture @Sendable non isolée est attendue. Le problème est une annotation manquante dans le framework, pas une limitation fondamentale du système de types.

  6. Le contrat Applications payantes est un prérequis au niveau du compte dans App Store Connect, distinct du statut de chaque produit IAP. Il exige des coordonnées bancaires, des formulaires fiscaux (W-8BEN-E) et une déclaration DSA. Le traitement peut prendre plusieurs jours. Tant que le contrat n’est pas au statut « Active », Product.products(for:) renvoie un tableau vide pour tous les produits payants dans le bac à sable d’Apple.

  7. L’ancienne API SKProductsRequest (StoreKit 1) offre un diagnostic légèrement meilleur : elle remplit un tableau invalidProductIdentifiers pour les identifiants non résolus. L’API Product.products(for:) de StoreKit 2 ne renvoie que le tableau vide, sans aucune indication de la cause.

  8. EXC_BREAKPOINT (SIGTRAP) prête à confusion. Dans un journal de plantage, cela signifie que le code a exécuté une instruction trap pour signaler l’échec d’une assertion fatale, pas qu’un point d’arrêt a été posé dans Xcode.