dart-lifecycle-disposed-flag-overload

Par divinevideo · divine-mobile

Corrige les services Dart/Flutter où appeler `start()` après `stop()` est un no-op silencieux, car `stop()` positionne un flag `_disposed` (ou similaire) sur lequel le garde de `start()` court-circuite. À utiliser quand : (1) Un repository/service/controller dispose de méthodes de cycle de vie `startListening`/`stopListening`, `subscribe`/`unsubscribe`, `open`/`close` ou similaires, (2) Rouvrir le service après l'avoir fermé ne semble rien faire — pas de souscription, pas d'événements, pas d'erreur, (3) Des tests unitaires qui n'exercent qu'un seul cycle mount/open/start passent alors que l'application réelle plante à la deuxième visite d'un écran, (4) Un flag booléen est utilisé à la fois pour « en train de démanteler définitivement cette instance » ET pour « actuellement arrêté, peut être redémarré ». Fréquent dans les écrans pilotés par Riverpod/Bloc qui câblent `startListening()` dans `initState` et `stopListening()` dans `dispose` — la deuxième fois que l'utilisateur visite l'écran, il ne se passe rien.

npx skills add https://github.com/divinevideo/divine-mobile --skill dart-lifecycle-disposed-flag-overload

Surcharge du drapeau _disposed du cycle de vie Dart

Problème

Une classe Dart/Flutter avec des méthodes de cycle de vie start/stop définit un booléen « disposed »/« stopped » à l'intérieur de stop(), mais la clause de garde dans start() court-circuite chaque fois que ce booléen est vrai. Après un cycle stop→start, start() revient silencieusement sans rien faire. La classe confond deux préoccupations distinctes dans un seul drapeau :

  1. « Cette instance est définitivement détruite » (par exemple, l'utilisateur a changé de compte, l'objet est supprimé) — doit empêcher tout travail ultérieur.
  2. « Actuellement pas à l'écoute, mais pourrait être redémarrée » (par exemple, l'utilisateur a quitté l'écran de la boîte de réception et peut y revenir) — doit permettre les appels start() futurs.

Quand ces préoccupations partagent un seul drapeau, la seconde préoccupation casse silencieusement la première.

Contexte / Conditions de déclenchement

  • Une classe (repository, service, controller, bloc, cubit, notifier) a des méthodes comme :
    • startListening() / stopListening()
    • subscribe() / unsubscribe()
    • connect() / disconnect()
    • open() / close()
  • stop() inclut une ligne comme _disposed = true; ou _stopped = true;
  • start() commence par une garde comme :
    if (_subscription != null || _disposed || !isInitialized) return;
  • Symptôme : la fonctionnalité fonctionne à la première ouverture, se casse à chaque ouverture ultérieure
  • Les tests unitaires qui simulent les dépendances passent car ils n'exercent qu'un seul cycle OU car la simulation ne modélise pas l'état interne réel de l'instance
  • L'assurance qualité manuelle trouve que quitter et revenir à un écran casse silencieusement la fonctionnalité (aucune erreur levée, aucun journal émis, aucune indication visible)

Solution

Séparez les deux préoccupations. Réservez le drapeau de démantèlement permanent pour le chemin de code qui déteint vraiment l'instance à jamais (typiquement un _resetState() appelé lors du changement d'utilisateur ou de la déconnexion complète), et NE le définissez PAS à l'intérieur de stop().

Avant (cassé)

class MyRepository {
  bool _disposed = false;
  StreamSubscription<Event>? _subscription;

  void startListening() {
    if (_subscription != null || _disposed || !isInitialized) return;
    _subscription = _client.subscribe(...).listen(...);
  }

  Future<void> stopListening() async {
    _disposed = true;                    // ← LE BUG
    await _subscription?.cancel();
    _subscription = null;
  }

  void _resetState() {
    _disposed = true;
    // ... effacer les credentials ...
    _disposed = false;
  }
}

Après stopListening(), _disposed == true à jamais jusqu'à ce que _resetState() soit appelé (ce qui ne se produit qu'au changement d'utilisateur). Tout appel ultérieur à startListening() atteint la garde et revient silencieusement.

Après (corrigé)

class MyRepository {
  bool _disposed = false;
  StreamSubscription<Event>? _subscription;

  void startListening() {
    // La garde vérifie toujours _disposed pour le cas du démantèlement permanent — cette
    // fenêtre n'est ouverte que pendant le corps synchrone de _resetState().
    if (_subscription != null || _disposed || !isInitialized) return;
    _subscription = _client.subscribe(...).listen(...);
  }

  Future<void> stopListening() async {
    // NE définissez PAS _disposed ici — _disposed est réservé à _resetState()
    // (démantèlement permanent, par exemple changement d'utilisateur). Le définir
    // rendrait un appel ultérieur à startListening() un non-op silencieux et casser les
    // flux de réouverture comme « l'utilisateur quitte l'écran et revient plus tard ».
    await _subscription?.cancel();
    _subscription = null;
  }

  void _resetState() {
    _disposed = true;
    // ... effacer les credentials, annuler la souscription, etc. ...
    _disposed = false;
  }
}

La moitié _subscription != null de la garde est toujours suffisante pour rendre startListening() idempotent contre les appels doubles au sein d'une seule durée d'écoute.

Vérification

  1. Ajoutez un test de régression qui exerce start → stop → start et affirme que le travail de start s'est produit deux fois :

    test('startListening after stopListening re-opens the subscription', () async {
      final repo = createRepository();
      repo.initialize(...);
    
      repo.startListening();
      await repo.stopListening();
      repo.startListening();
    
      // Les deux ouvertures doivent frapper le client.
      verify(() => mockClient.subscribe(any(), ...)).called(2);
    
      await repo.stopListening();
    });
  2. Assurance qualité manuelle : visitez l'écran qui pilote le cycle de vie, sortez de celui-ci, visitez-le à nouveau. La fonctionnalité doit fonctionner à la deuxième visite de façon identique à la première.

  3. Exécutez le test existant pour le chemin de démantèlement permanent (par exemple changement d'utilisateur / _resetState()) et confirmez qu'il passe toujours. La correction ne doit pas affecter ce chemin.

Exemple

À partir de divine-mobile (PR #2769, avril 2026) : DmRepository piloté le cycle de vie de la souscription gift-wrap NIP-17 à partir de initState/dispose de l'écran de la boîte de réception. À la deuxième visite à la boîte de réception, les DM ont silencieusement arrêté d'arriver. Cause racine : stopListening() avait _disposed = true; comme première ligne. Correction : supprimer cette ligne, laisser un commentaire explicatif, ajouter un test de régression qui affirme que mockNostrClient.subscribe a été appelé deux fois après un cycle open → close → open. Commit bd1420eb3 fix(dm): allow startListening() to succeed after stopListening().

Remarques

  • Pourquoi les mocks cachent ce bug : les tests unitaires qui simulent la dépendance (par exemple un NostrClient simulé) vérifient seulement que le repository appelle subscribe() une fois quand startListening() est appelé. Ils n'exercent pas la machine à états réelle sur plusieurs cycles à moins que le test cycle explicitement start→stop→start et vérifie que le deuxième start a aussi appelé subscribe. Ajoutez ce cycle à votre suite de tests du cycle de vie de manière préventive.
  • Nom alternatif pour le drapeau : si vous avez besoin de deux drapeaux car les deux préoccupations existent vraiment, nommez-les pour leur signification réelle : _permanentlyDisposed (ou _torn_down) vs _isListening (ou _started). Un single bool avec une signification surchargée est l'odeur racine.
  • Liaison du cycle de vie Riverpod/Bloc : ce bug est particulièrement courant quand un écran câble startListening() dans initState et stopListening() dans dispose et l'utilisateur peut quitter et revenir à l'écran. Si ce flux est nouveau, ajoutez toujours un test « visiter deux fois » à votre test de widget pour cet écran.
  • Attention aux chemins de reconnexion asymétriques : les callbacks onDone sur les streams annulés peuvent aussi lire le drapeau et décider de programmer une reconnexion. Après séparation des drapeaux, auditez chaque lecture de l'ancien drapeau pour confirmer que la nouvelle sémantique correspond toujours à l'intention du site d'appel.

Références

Skills similaires