Une même requête passe par trois étapes asynchrones — téléchargement, analyse et écriture sur disque — mais son identifiant apparaît de façon intermittente dans les journaux. Après avoir remplacé cet identifiant par une variable globale, les tests parallèles commencent à mélanger les valeurs. Pour diagnostiquer ce type de problème sur un Mac cloud, inutile de commencer par modifier le nombre de threads : il faut vérifier dans quelles tâches le contexte se propage et à quelle frontière il s’arrête.
Cet article utilise un package Swift autonome pour tester TaskLocal, sans connexion aux services applicatifs ni dépendance à un simulateur. Il vérifie la sémantique des tâches, pas leur affectation à un thread : lorsqu’une même tâche change de thread d’exécution, elle ne doit pas perdre ses valeurs locales.
Définir d’abord le contrat de propagation
Définissez l’identifiant de requête comme une chaîne optionnelle, dont la valeur par défaut est nil. En l’absence de contexte de requête, conserver cette valeur permet de repérer plus facilement une propagation manquante que de générer automatiquement un identifiant d’apparence valide.
Le contrat comporte cinq règles : la valeur est accessible dans sa portée ; une portée imbriquée peut la remplacer temporairement ; la sortie de cette portée restaure la valeur externe ; les tâches enfants ordinaires et celles d’un groupe de tâches héritent du contexte présent à leur création ; les tâches détachées n’en héritent pas par défaut. La levée d’une erreur doit également restaurer l’état externe.
TaskLocal n’est ni un stockage local au thread, ni une enveloppe rendant une variable globale sûre. C’est le contexte de la tâche qui se propage, pas la dernière valeur écrite sur un thread donné.
Les valeurs doivent aussi rester aussi petites et immuables que possible. Ici, nous transmettons uniquement des chaînes, pas de dictionnaires mutables, d’objets de session ou de connexions à une base de données. Placer un objet de type référence dans TaskLocal ne le rend pas automatiquement sûr en accès concurrent.
Créer un package de test autonome
Exécutez les commandes suivantes dans un répertoire de travail ordinaire du Mac cloud. Commencez par relever la version de la chaîne d’outils : les rapports d’échec permettront ainsi de distinguer une modification du code d’un changement de compilateur.
mkdir TraceScopeLab
cd TraceScopeLab
swift --version
swift package init --type library --name TraceScopeLab
mkdir -p Tests/TraceScopeLabTests
Créez un fichier ContextTests.swift dans le répertoire de tests et insérez-y le code complet ci-dessous. Vous pouvez conserver le fichier de test généré par défaut : les deux sont indépendants. Cet exemple utilise directement le framework de test fourni avec la chaîne d’outils, sans dépendance supplémentaire.
import XCTest
private enum TraceContext {
@TaskLocal static var requestID: String?
}
private enum ProbeError: Error {
case expected
}
final class ContextTests: XCTestCase {
func testNestedScopeRestoresAfterThrow() {
XCTAssertNil(TraceContext.requestID)
TraceContext.$requestID.withValue("outer") {
XCTAssertEqual(TraceContext.requestID, "outer")
XCTAssertThrowsError(
try TraceContext.$requestID.withValue("inner") {
XCTAssertEqual(TraceContext.requestID, "inner")
throw ProbeError.expected
}
)
XCTAssertEqual(TraceContext.requestID, "outer")
}
XCTAssertNil(TraceContext.requestID)
}
func testTaskBoundaries() async {
let observed = await TraceContext.$requestID
.withValue("request-a") {
let child = Task {
TraceContext.requestID
}
let detached = Task.detached {
TraceContext.requestID
}
let inherited = await child.value
let absent = await detached.value
let groupValue = await withTaskGroup(
of: String?.self,
returning: String?.self
) { group in
group.addTask {
TraceContext.requestID
}
var result: String?
for await value in group {
result = value
}
return result
}
return (inherited, absent, groupValue)
}
XCTAssertEqual(observed.0, "request-a")
XCTAssertNil(observed.1)
XCTAssertEqual(observed.2, "request-a")
XCTAssertNil(TraceContext.requestID)
}
}
Nous récupérons d’abord les résultats avec await, puis les transmettons aux assertions, afin de ne pas introduire de lecture asynchrone dans une expression d’assertion synchrone. Toutes les tâches créées sont attendues jusqu’à leur fin : aucun travail ne reste en arrière-plan une fois le test terminé.
Choisir explicitement le contexte des tâches détachées
Task {} hérite des valeurs locales à la tâche, mais crée une tâche non structurée : sa durée de vie n’est pas automatiquement liée à la portée qui l’a créée. Hériter du contexte et attendre automatiquement la fin d’une tâche sont deux choses distinctes.
Une tâche créée précédemment peut donc conserver l’identifiant d’origine après la sortie de la portée parente. Cela ne signifie pas que la restauration a échoué. Pour le travail effectué dans le cadre d’une requête, privilégiez la concurrence structurée ; si une tâche non structurée est réellement nécessaire, conservez sa référence et prévoyez explicitement de l’attendre ou de l’annuler.
Pour un traitement indépendant en arrière-plan, l’absence de contexte de requête par défaut rend les choses plus claires. Si une corrélation est nécessaire pour le traçage, capturez d’abord les champs dont la propagation est autorisée, puis liez-les à l’intérieur de la tâche détachée :
let capturedID = TraceContext.requestID
let worker = Task.detached {
TraceContext.$requestID.withValue(capturedID) {
TraceContext.requestID
}
}
let observedID = await worker.value
Placez ce code dans une méthode de test asynchrone du fichier précédent. La capture se fait du côté qui crée la tâche, et la liaison dans la nouvelle tâche : n’attendez pas d’être dans la tâche détachée pour lire l’ancien contexte. Les opérations sans rapport avec la requête, comme un nettoyage périodique, doivent conserver nil pour éviter de rattacher à tort tout un ensemble de journaux à une seule requête utilisateur.
Vérifier l’isolation lors d’exécutions entrelacées
La réussite d’une seule requête ne suffit pas. Ajoutez une méthode de test asynchrone qui crée simultanément deux tâches, l’une liant request-a, l’autre request-b. Dans chaque portée de liaison, appelez await Task.yield(), puis renvoyez l’identifiant courant après la reprise. Vérifiez enfin chaque résultat par une assertion distincte.
N’imposez pas d’ordre de terminaison aux deux tâches. Task.yield() ne fait que suggérer de céder l’exécution ; il ne garantit aucun ordre d’entrelacement. Le critère de validation doit être « quelle que soit la tâche qui termine en premier, chacune conserve la bonne valeur », et non « la première ligne de journal provient forcément de A ».
Après la fin des deux portées asynchrones, vérifiez également que la valeur par défaut reste nil. N’utilisez ni tableau partagé pour recueillir les résultats, ni compteur global pour coordonner l’ordre d’exécution : le test lui-même risquerait d’introduire une condition de concurrence.
Produire des rapports d’échec exploitables
Exécutez les tests ciblés, puis relancez-les en activant l’exécution parallèle. Ce second passage ne remplace pas les assertions explicites d’isolation, mais peut révéler une contamination entre tests par un état partagé.
swift test --filter ContextTests
swift test --parallel --filter ContextTests
Si une tâche enfant ordinaire lit nil, vérifiez d’abord qu’elle est réellement créée à l’intérieur de withValue. Si une tâche détachée lit un identifiant, recherchez une liaison explicite ou une couche d’encapsulation supplémentaire. Si la sortie d’une portée imbriquée ne semble pas restaurer la valeur, assurez-vous que la lecture porte bien sur TaskLocal et non sur une propriété globale du même nom.
Un rapport d’échec n’a besoin de contenir que la version de la chaîne d’outils, le nom du test, la valeur attendue et la valeur obtenue. Il n’est pas nécessaire de recopier de véritables identifiants de requête issus des journaux de production dans les données de test.
Enfin, séparez le traçage de l’autorisation : le contexte permet de relier les étapes d’une opération, mais ne prouve pas que l’appelant a le droit de l’exécuter. Pour que les conclusions des tests parallèles restent fiables dans la durée, propagez de petites valeurs à travers des frontières de tâches explicites et vérifiez les droits à partir d’entrées explicites.
Questions fréquentes
Task.detached hérite-t-il des valeurs TaskLocal ?
Non. Lisez la valeur utile dans la tâche appelante, puis créez explicitement une nouvelle portée avec withValue dans la tâche détachée.
TaskLocal peut-il servir de seule preuve d’autorisation ?
Non. Utilisez-le pour le contexte de traçage, pas comme preuve d’identité. Transmettez et vérifiez explicitement les informations d’autorisation.
Confiez votre prochain build à un Mac dédié dans le cloud
MacFTP propose des machines physiques dédiées Apple Silicon exécutant macOS en natif, avec des locations à la journée, à la semaine, au mois ou au trimestre. Vérifiez la configuration, puis connectez votre chaîne d’outils au nœud ; le statut réel de disponibilité est indiqué en temps réel dans la console.