Lorsqu’une équipe se relaie entre plusieurs fuseaux horaires pour travailler sur du code asynchrone exécuté sur un Mac dans le cloud, l’un des problèmes les plus difficiles à reproduire est le suivant : l’interface signale déjà un dépassement de délai, mais le test ne se termine toujours pas. En général, ce n’est pas le minuteur qui est défaillant : c’est l’opération sous-jacente qui ne réagit pas à l’annulation. Placer le travail et le minuteur dans un groupe de tâches les met en concurrence, sans pour autant permettre d’interrompre de force n’importe quel code.
Nous allons établir une base exécutable avec la concurrence structurée de Swift, puis vérifier séparément le résultat, l’annulation et la sortie effective. Les exemples nécessitent une chaîne de compilation prenant en charge Duration et Task.sleep(for:), ainsi que macOS 13 ou une version ultérieure pour l’exécution en ligne de commande. Lors du passage de relais, l’équipe doit consigner la version du compilateur réellement utilisée.
Définir le contrat du délai avant d’écrire le minuteur
Le contrat tient en trois règles : si le travail se termine en premier, renvoyer son résultat ; si la fin de la branche de temporisation est observée en premier, lever une erreur de dépassement de délai ; si la tâche parente est annulée, propager cette annulation sans la transformer en dépassement de délai applicatif. Lorsque les deux branches se terminent presque simultanément, aucun vainqueur précis n’est garanti.
Il faut aussi distinguer une attente relative d’une échéance stricte. Dans l’implémentation ci-dessous, le décompte commence lorsque la branche de temporisation entame son attente : il ne fixe pas une durée maximale absolue de réponse à partir de l’entrée dans la fonction. Une attente très courte ne supprime pas non plus les retards d’ordonnancement.
Le déclenchement du délai, l’envoi d’une demande d’annulation et la sortie de l’opération sous-jacente sont trois événements distincts. Ce n’est qu’après le dernier que le groupe de tâches peut se terminer.
Si les exigences applicatives imposent de rendre la main dès l’échéance atteinte, il n’est pas possible d’affirmer en même temps que tout le travail reste encadré par ce groupe de tâches. Il faut revoir l’interface d’annulation de l’opération sous-jacente, ou placer le travail non interruptible dans un processus distinct dont on gère le cycle de vie.
Implémenter une course avec une logique d’attente injectable
Enregistrez le contenu suivant dans Timeout.swift. La closure d’attente peut utiliser une horloge réelle ou être remplacée, dans les tests, par une barrière contrôlable. Le résultat du travail doit pouvoir être transmis d’une tâche à une autre.
import Foundation
enum TimeoutFailure: Error {
case expired
}
func raceTimeout<Value: Sendable>(
wait: @escaping @Sendable () async throws -> Void,
operation: @escaping @Sendable () async throws -> Value
) async throws -> Value {
try Task.checkCancellation()
return try await withThrowingTaskGroup(of: Value.self) { group in
defer { group.cancelAll() }
group.addTask {
try Task.checkCancellation()
return try await operation()
}
group.addTask {
try Task.checkCancellation()
try await wait()
try Task.checkCancellation()
throw TimeoutFailure.expired
}
guard let value = try await group.next() else {
throw CancellationError()
}
try Task.checkCancellation()
return value
}
}
Vérifiez d’abord les types et les contraintes de concurrence avec cette commande de compilation, puis intégrez le code aux tests du projet :
xcrun swiftc -swift-version 6 -strict-concurrency=complete \
-parse-as-library -emit-module \
-module-name Timeout Timeout.swift \
-emit-module-path /tmp/Timeout.swiftmodule
Voici un exemple d’appel avec une temporisation réelle. L’appelant doit se trouver dans un contexte asynchrone :
let result = try await raceTimeout(
wait: { try await Task.sleep(for: .seconds(2)) },
operation: {
try await Task.sleep(for: .milliseconds(20))
return "ready"
}
)
Le bloc defer garantit qu’une demande d’annulation est envoyée à la branche restante, que l’exécution réussisse ou lève une erreur. L’annulation du minuteur provoque une erreur ; ne l’absorbez pas avec try?, sinon la branche risque de poursuivre son exécution et de signaler à tort un dépassement de délai.
Fixer l’ordre avec des barrières plutôt que compter sur des pauses
Les tests unitaires essentiels ne doivent pas reposer sur le principe « attendre un peu, puis deviner quelle branche s’est exécutée en premier ». Créez deux barrières de test pour contrôler séparément la fin du travail et celle de la temporisation. Ajoutez des signaux de démarrage pour confirmer que les deux branches sont entrées en attente avant d’ouvrir la barrière voulue.
Chaque barrière doit mémoriser l’état d’annulation et libérer les tâches en attente au moyen d’un gestionnaire d’annulation. Si elle utilise une continuation en interne, celle-ci doit être reprise exactement une fois. Il faut aussi couvrir le cas où l’annulation arrive avant l’enregistrement de la tâche en attente. Sinon, le dispositif de test peut lui-même provoquer un blocage.
Conservez au minimum les quatre ensembles d’assertions suivants :
- Le travail gagne : la valeur attendue est renvoyée et la fin de l’attente du minuteur est observée.
- Le minuteur gagne : l’erreur obtenue est
TimeoutFailure.expiredet le travail se termine après réception de l’annulation. - Le travail échoue : l’erreur applicative d’origine est préservée, sans être transformée en dépassement de délai.
- La tâche parente est annulée : une fois les deux branches démarrées, annulez la tâche parente, puis vérifiez la propagation de l’annulation et tous les signaux de fin.
Pour chaque ensemble, ne vérifiez pas seulement le résultat de l’appel : attendez aussi les marqueurs de fin des deux branches. Un résultat correct ne suffit pas à valider le test si une tâche enfant n’est pas terminée.
Prouver qu’un travail qui ignore l’annulation bloque la sortie
Ajoutez une barrière de travail qui ignore délibérément l’annulation, mais que le test peut ouvrir. Faites d’abord gagner la branche de temporisation, puis confirmez que le travail a reçu la demande d’annulation alors que l’appel englobant n’est toujours pas terminé. Enfin, ouvrez la barrière du travail et vérifiez que l’appel englobant ne lève l’erreur de dépassement de délai qu’à ce moment-là.
Ce test démontre la sémantique d’attente de la concurrence structurée ; il n’encourage pas à ignorer l’annulation. Le test doit prévoir un mécanisme de libération de secours. Ne simulez pas un blocage avec une boucle infinie et ne laissez pas une continuation impossible à reprendre après l’échec d’une assertion.
Dans le code de production, vérifiez toute la chaîne d’appels : les boucles longues exécutent-elles régulièrement Task.checkCancellation() ? L’adaptation des callbacks fait-elle appel au mécanisme d’annulation sous-jacent ? Les appels bloquants peuvent-ils se terminer ? Une vérification unique à l’entrée de l’opération ne couvre pas le travail de longue durée qui suit.
Transmettre aussi les preuves de sortie à l’équipe suivante
Lors des exécutions sur un Mac dans le cloud, joignez aux tests l’identifiant du commit, la version du compilateur, le nom du test, la branche gagnante, les observations d’annulation et les deux marqueurs de fin. Calculez les durées avec une horloge monotone, et non en soustrayant les heures affichées sur les postes de différents membres de l’équipe.
Les tests avec une horloge réelle servent uniquement à vérifier que la closure d’attente de production est correctement raccordée. Prévoyez une marge pour l’ordonnancement, sans exiger une précision à la milliseconde près. Le processus de test doit également disposer d’une limite d’exécution indépendante : un groupe de tâches qui ne se termine pas ne peut pas compter sur sa propre tâche de temporisation pour se débloquer.
Pour finir, vérifiez que la gestion des erreurs ne transforme pas systématiquement CancellationError en dépassement de délai, que la closure d’attente n’absorbe pas les erreurs et que les chemins d’échec conservent les preuves de sortie. Le passage de relais n’est pas terminé parce que « le minuteur s’est déclenché », mais lorsque le résultat est explicable, l’annulation observable et le travail restant effectivement terminé.
Questions fréquentes
La fonction retourne-t-elle dès que le minuteur lève une erreur ?
Pas forcément. Le groupe attend la fin de toutes ses tâches enfants avant de quitter sa portée. cancelAll demande leur annulation sans imposer leur arrêt.
Faut-il attendre plusieurs secondes dans chaque test ?
Non. Injectez une attente contrôlée et imposez l’ordre de fin avec des barrières de test. Réservez le temps réel à quelques tests d’intégration, avec une limite d’exécution indépendante.
Choisissez un Mac cloud pour votre prochain projet
Choisissez un emplacement proche de celui où se trouve principalement votre équipe, avec un Mac mini physique qui vous est entièrement dédié. Le bureau à distance dans le navigateur nécessite un navigateur récent et une connexion stable. Vérifiez la configuration, la durée de facturation et la disponibilité avant de commander.