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 :
- « 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.
- « 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
-
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(); }); -
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.
-
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
NostrClientsimulé) vérifient seulement que le repository appellesubscribe()une fois quandstartListening()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 singleboolavec 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()dansinitStateetstopListening()dansdisposeet 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
onDonesur 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
- Documentation de Dart
StreamSubscription.cancel(): https://api.dart.dev/stable/dart-async/StreamSubscription/cancel.html (l'annulation ne remet pas d'événementdoneà l'écouteur, ce qui est pertinent lors de l'audit des chemins de reconnexion onDone après cette correction.) - Cycle de vie Flutter (
State.initState/State.dispose) : https://api.flutter.dev/flutter/widgets/State-class.html