Tester la réentrance des actors Swift et la réservation des quotas

Tester la réentrance des actors Swift et la réservation des quotas

Sur un Mac dans le cloud, deux demandes d’export obtiennent toutes les deux la dernière place disponible, alors qu’elles passent par le même contrôle dans un actor. Il ne s’agit pas forcément d’une course aux données : après avoir vérifié la capacité, la première demande attend une préparation asynchrone, et la seconde s’engouffre dans cet intervalle. Ce qu’il faut valider, c’est le maintien de la contrainte métier de part et d’autre d’un await, pas la capacité de l’actor à sérialiser les lectures et les écritures de variables.

Définir clairement la contrainte de capacité

Supposons qu’un service d’export autorise au plus une tâche à occuper l’espace de travail temporaire. La capacité utilisée doit inclure les tâches « en préparation » comme celles « en cours d’exécution », et pas seulement les tâches dont la préparation est terminée. Sinon, une préparation lente devient un moyen de contourner la limite.

L’enchaînement dangereux consiste à « vérifier qu’une place est libre, attendre la préparation, puis augmenter l’occupation ». Pendant l’attente, l’actor peut traiter d’autres appels : le résultat du contrôle ne reflète donc plus nécessairement l’état à la reprise. Il faut au contraire vérifier la capacité et réserver la place dans une section de code sans point de suspension, puis lancer l’opération asynchrone.

L’isolation d’un actor protège l’accès à l’état ; elle ne transforme pas une méthode asynchrone entière en transaction indivisible. À chaque await, posez-vous cette question : les conditions dont je dépends seront-elles encore vraies à la reprise ?

Dans cet exemple, un appel est immédiatement refusé lorsque la capacité est épuisée, sans file d’attente. La mise en file, l’équité et les délais d’attente relèvent d’un autre contrat : des tentatives répétées indéfiniment côté appelant ne peuvent pas en tenir lieu.

Représenter la propriété d’une réservation par un jeton

Sur le Mac dans le cloud, créez un projet macOS nommé ActorQuota et ajoutez une cible de tests unitaires XCTest. Placez le code suivant dans le fichier de test, à la place de l’exemple par défaut. Il s’agit de vérifier une logique de concurrence Swift partagée ; inutile de lancer le simulateur iOS.

import Foundation
import XCTest

enum QuotaError: Error {
    case full
}

actor ReservationPool {
    private let capacity: Int
    private var active: Set<UUID> = []

    init(capacity: Int) {
        precondition(capacity > 0)
        self.capacity = capacity
    }

    var count: Int { active.count }

    func run(
        _ operation: @Sendable () async throws -> Void
    ) async throws {
        try Task.checkCancellation()
        guard active.count < capacity else {
            throw QuotaError.full
        }

        let token = UUID()
        active.insert(token)
        defer { active.remove(token) }

        try await operation()
        try Task.checkCancellation()
    }
}

Il n’y a aucun await entre le contrôle et l’insertion : aucun autre appel ne peut donc s’intercaler entre les deux. Chaque appel détient son propre jeton et ne libère que sa propre réservation à sa sortie. Un chemin d’erreur ne risque ainsi pas de diminuer l’occupation attribuée à un autre appel.

defer couvre aussi bien le retour normal que la sortie par erreur, mais ce n’est pas une solution universelle de gestion des ressources. L’opération passée en argument doit attendre la fin du travail réel avant de rendre la main. Si elle lance une tâche dont la durée de vie dépasse celle de l’appel et revient immédiatement, la place sera tout de même libérée trop tôt. Le contrat d’appel doit être examiné en même temps que l’implémentation.

Maîtriser l’entrelacement sans miser sur des pauses temporisées

Ajoutez un point de pause contrôlable à la première opération. Le deuxième appel doit avoir lieu après la réservation et avant sa libération. Une pause de durée fixe ne fait que supposer où en est l’exécution ; avec une charge machine différente, le test peut réussir de façon intermittente.

Ajoutez à la suite, dans le même fichier, ce dispositif de test à usage unique et la classe de tests. Le dispositif peut être ouvert avant même qu’un appel ne se mette en attente, ce qui évite une suspension permanente si le signal arrive en premier.

actor PauseGate {
    private var opened = false
    private var waiter: CheckedContinuation<Void, Never>?

    func pause() async {
        if opened { return }
        await withCheckedContinuation { continuation in
            waiter = continuation
        }
    }

    func open() {
        opened = true
        waiter?.resume()
        waiter = nil
    }
}

final class ReservationTests: XCTestCase, @unchecked Sendable {
    func testRejectsOverlapAndReleases() async throws {
        let pool = ReservationPool(capacity: 1)
        let gate = PauseGate()
        let entered = expectation(description: "Operation entered")

        let first = Task {
            try await pool.run {
                entered.fulfill()
                await gate.pause()
            }
        }

        await fulfillment(of: [entered], timeout: 2.0)

        let during = await pool.count
        XCTAssertEqual(during, 1)

        do {
            try await pool.run {}
            XCTFail("Overlapping operation was accepted")
        } catch QuotaError.full {
        }

        await gate.open()
        try await first.value

        let after = await pool.count
        XCTAssertEqual(after, 0)

        try await pool.run {}
    }
}

Lorsque l’attente XCTest est satisfaite, le premier appel est déjà entré dans le corps de l’opération : la réservation existe donc nécessairement. Les deux secondes constituent le délai maximal pour atteindre ce point du dispositif de test, et non un critère de performance. Ce dispositif ne prend en charge qu’un seul appel en attente ; ne l’utilisez pas tel quel comme composant de diffusion en production.

Vérifier séparément les erreurs et l’annulation

Ajoutez un cas de test où l’opération lève immédiatement une erreur propre au test. Interceptez cette erreur, vérifiez son type, puis lisez count. Le résultat doit être zéro, et l’appel suivant doit lui aussi réussir. Se contenter de vérifier qu’une erreur a été levée laisserait passer le cas où l’opération échoue tout en conservant sa place réservée.

Pour le test d’annulation, réutilisez le point de pause : attendez que la première opération soit entrée, puis appelez first.cancel(). À ce stade, l’occupation doit toujours être égale à un. Ouvrez ensuite le dispositif de test et attendez le résultat de la tâche : vous devez recevoir une CancellationError, et l’occupation doit finalement retomber à zéro.

Cette sémantique est volontairement prudente. L’annulation n’émet qu’un signal coopératif ; le dispositif de pause de cet exemple ne reprend pas automatiquement lorsqu’il est annulé. Libérer la capacité avant la fin effective de l’opération sous-jacente permettrait à une nouvelle tâche de s’exécuter en chevauchement avec l’ancienne.

Si l’API d’export réelle prend en charge l’annulation, transmettez-lui le signal et attendez qu’elle confirme la fin de l’opération. Si l’opération a déjà produit des effets irréversibles, le contrôle d’annulation final ne peut pas les défaire. Il faut alors définir séparément le point de validation et la façon de consulter le résultat : « lever une erreur d’annulation » ne signifie pas que « rien ne s’est passé ».

Intégrer les tests au build et conserver un contre-exemple

Exécutez la commande suivante à la racine du projet. Le nom du projet et le schéma partagé doivent rester conformes aux étapes de création précédentes ; la classe de tests doit appartenir à la cible ActorQuotaTests.

set -euo pipefail
xcodebuild \
  -project ActorQuota.xcodeproj \
  -scheme ActorQuota \
  -destination 'platform=macOS' \
  -only-testing:ActorQuotaTests/ReservationTests \
  test

Conservez d’abord le résultat positif obtenu avec l’implémentation correcte. Puis, sur une branche temporaire, déplacez volontairement la réservation après l’opération asynchrone. Le test de chevauchement doit échouer ; s’il réussit encore, c’est qu’il ne couvre pas réellement l’intervalle entre le contrôle de capacité et la prise d’une place.

Vérifiez finalement quatre points : la réservation existe avant l’attente, un appel refusé ne modifie pas l’occupation, une erreur ne libère que le jeton de l’appel concerné, et une annulation ne rend la capacité qu’après la fin effective de l’opération. Passez ensuite la capacité à deux, mettez deux opérations en pause et refusez la troisième, afin de vérifier que l’implémentation n’a pas réduit la limite à un simple indicateur booléen.

Commitez ces tests avec l’implémentation du pool de ressources. Lors d’une future refonte de la préparation asynchrone, ils protégeront la limite de capacité réelle bien mieux que la simple affirmation « on utilise un actor, donc c’est sûr ».

Questions fréquentes

Pourquoi un actor peut-il dépasser sa capacité ?

Un await peut laisser entrer un autre appel. Si le premier appel a vérifié la capacité sans réserver de place, le suivant peut observer la même disponibilité.

Faut-il libérer la réservation dès l’annulation ?

Non, pas si l’opération utilise encore la ressource. La réservation doit rester active jusqu’à l’arrêt effectif ou au retour de cette opération.

Ajoutez un Mac dédié à votre workflow

Utilisez macOS natif pour compiler, tester et expérimenter. Comparez les configurations et les durées de facturation, puis choisissez le nœud physique qui vous convient.

Louer un Mac dans le cloud Voir les configurations

Confiez votre prochaine compilation à un Mac dans le cloud

Un ordinateur physique dédié, pas une machine virtuelle. Choisissez la configuration et la durée de location adaptées à votre projet.