CurrentValueSubject, pas Passthrough : il fallait réveiller AVANT, pas après

La lecture des sources tranche ce que trois essais n'avaient pas résolu, et
elle retourne mon raisonnement précédent.

`bleStateSubject` est un `CurrentValueSubject<BleState, Never>(.unknown)` : il
rejoue sa valeur à chaque abonnement. Être abonné au moment de l'émission
n'apporte donc rien — et le correctif intermédiaire, qui réveillait le manager
APRÈS s'être abonné « pour ne pas rater l'événement », était un raisonnement de
PassthroughSubject appliqué au mauvais type.

Pire, il exposait à un second piège : `centralManagerDidUpdateState` fait
`BleState(rawValue: self.manager.state.rawValue)`, soit un accès à la lazy var
DEPUIS le délégué. Réveillée au milieu d'un abonnement, la propriété peut se
réentrer avant la fin de sa propre initialisation.

Séquence corrigée : créer le listener, poser le filtre, sonder `blePowered()`
jusqu'à ce qu'il réponde vrai — hors de tout abonnement, le premier appel
instancie, les suivants observent — puis appeler search() une seule fois. Si
l'état n'est pas atteint en 10 s, on le dit au lieu de scanner dans le vide.

Le commentaire qui affirmait « bleStateSubject ne publie que sur changement » est
corrigé plutôt que laissé : il était faux, et un commentaire qui ment coûte plus
cher que pas de commentaire. L'abonnement unique reste, mais pour la vraie
raison — chaque abonnement relance le cycle addClient/removeClient du scanner, et
la découverte BLE demande plusieurs secondes ininterrompues.

82 tests au vert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-31 13:27:58 +00:00
parent 0f96122cca
commit 753d7b248e
2 changed files with 80 additions and 37 deletions

View File

@@ -166,13 +166,28 @@ abonné à `search()`. L'ordre est critique : si `bleStateSubject` est un
`PassthroughSubject`, un état émis avant l'abonnement serait perdu et on
retomberait sur le même silence.
## 🔴 ÉTAT AU SOIR DU 31/08 : bloqué, et pourquoi il faut changer d'approche
## Le démarrage du SDK — deux détails qui décident de tout
Malgré le réveil du manager, le filtre Polar et l'abonnement unique, le
publisher de `search()` **reste muet** : `bleStateSubject` n'émet jamais
`.poweredOn`. Le manager du SDK existe pourtant — l'avertissement CoreBluetooth
`SDKCBCentralManager … has no restore identifier` ne peut apparaître que s'il a
été instancié.
Lus dans les sources, après plusieurs essais infructueux le 31/08 :
**1. `bleStateSubject` est un `CurrentValueSubject<BleState, Never>(.unknown)`**,
pas un PassthroughSubject. Il **rejoue sa valeur à chaque abonnement** : il est
donc inutile d'être abonné au moment de l'émission. Un correctif intermédiaire
réveillait le manager *après* s'être abonné « pour ne pas rater l'événement » —
raisonnement juste pour un PassthroughSubject, faux ici, et qui exposait au
piège suivant.
**2. `centralManagerDidUpdateState` fait
`BleState(rawValue: self.manager.state.rawValue)`** — il accède à la `lazy var`
depuis le délégué. Si CoreBluetooth appelle le délégué avant que l'initialisation
de la lazy soit terminée, la propriété se réentre.
**Séquence correcte** : créer le listener, poser `scanPreFilter`, réveiller la
lazy en sondant `blePowered()` jusqu'à ce qu'elle réponde vrai — hors de tout
abonnement — et seulement ensuite appeler `search()`, une fois, sur toute la
durée. Un abonnement par tranche relancerait le cycle
`addClient()`/`removeClient()` du scanner et l'empêcherait de découvrir quoi que
ce soit.
**Ce qui a été corrigé et vérifié en chemin** (ne pas refaire) :

View File

@@ -228,6 +228,42 @@ final class PolarPsFtpWriter {
|| contenu.name.lowercased().contains("polar")
}
// RÉVEILLER LA LAZY, PUIS ATTENDRE dans cet ordre, et avant tout
// abonnement.
//
// `CBDeviceListenerImpl.manager` est une `lazy var` : le
// CBCentralManager n'existe qu'au premier accès, et `search()` ne le
// touche jamais avant son filtre `$0 == .poweredOn`. Sans réveil, on
// attend un état que rien ne peut produire.
//
// Deux détails, tirés des sources, qui décident du succès :
//
// 1. `bleStateSubject` est un **CurrentValueSubject**, initialisé à
// `.unknown`. Il REJOUE sa valeur à chaque abonnement inutile donc
// d'être abonné au moment de l'émission. Une version précédente
// réveillait après l'abonnement « pour ne pas rater l'événement » :
// raisonnement de PassthroughSubject, faux ici, et qui exposait au
// piège suivant.
//
// 2. `centralManagerDidUpdateState` fait
// `BleState(rawValue: self.manager.state.rawValue)` il accède à la
// lazy DEPUIS le délégué. Si CoreBluetooth appelle le délégué avant
// que l'initialisation de la lazy soit terminée, la propriété se
// réentre. D'où : réveiller tôt, hors de tout abonnement, et laisser
// l'état se poser.
//
// On attend donc que `blePowered()` réponde vrai ce qui signifie que
// le délégué a tourné et que `.poweredOn` est dans le sujet avant
// d'appeler `search()`.
let pret = await Self.attendre(seconds: min(timeout, 10)) {
listener.blePowered()
}
if !pret {
throw PolarPftpError.bluetoothUnusable(
"le listener du SDK Polar n'a pas atteint l'état « prêt ». "
+ "Bluetooth actif mais inutilisable par le SDK.")
}
let (client, session) = try await trouverClient(listener, timeout: timeout,
sonde: sonde)
defer { listener.closeSessionDirect(session) }
@@ -255,16 +291,15 @@ final class PolarPsFtpWriter {
// UN SEUL ABONNEMENT, SUR TOUTE LA DURÉE pas des fenêtres.
//
// Le découpage en cycles de 4 s se privait de l'unique émission d'état :
// `bleStateSubject` ne publie que sur CHANGEMENT. Le premier cycle
// réveillait le manager et recevait `.poweredOn` ; aux cycles suivants
// l'état ne changeait plus, donc plus rien n'était émis et le publisher
// restait muet mesuré le 2026-08-31, y compris après avoir réglé le
// réveil du manager.
// Non pas parce qu'une émission serait ratée `bleStateSubject` est un
// CurrentValueSubject, il rejoue sa valeur à chaque abonnement mais
// parce que chaque abonnement relance le cycle `addClient()` /
// `removeClient()` du scanner. Redémarrer un scan toutes les 4 s
// l'empêche de découvrir quoi que ce soit, et la découverte BLE demande
// plusieurs secondes ininterrompues.
//
// Une seule fenêtre, de la durée du timeout, supprime le problème : on
// reçoit l'émission là où elle a lieu, et le scan tourne sans être
// relancé.
// L'état, lui, est acquis avant d'arriver ici : `blePowered()` a été
// sondé jusqu'à devenir vrai.
do {
let sessions = try await sessionsVues(listener, fenetre: timeout)
vues = sessions.count
@@ -338,28 +373,6 @@ final class PolarPsFtpWriter {
})
.store(in: &abonnements)
// RÉVEIL DE LA PROPRIÉTÉ LAZY SANS ÇA, RIEN NE SE PASSE.
//
// `CBDeviceListenerImpl.manager` est une `lazy var` : le
// CBCentralManager n'existe qu'au premier accès. Or `search()`
// commence par `monitorBleState().filter { $0 == .poweredOn }`, et
// `monitorBleState()` ne fait que rendre `bleStateSubject` il ne
// touche jamais `manager`. Les seuls accès du chemin de recherche
// sont À L'INTÉRIEUR du `flatMap`, donc après le filtre.
//
// Résultat mesuré le 2026-08-31 : le manager n'était jamais créé,
// `centralManagerDidUpdateState` jamais appelé, le sujet n'émettait
// jamais, et le publisher restait muet ni valeur, ni fin, ni
// erreur. 43 appareils vus par un scan CoreBluetooth nu, 0 par le
// SDK.
//
// `blePowered()` lit `manager.state` : c'est le seul accès public
// exécuté immédiatement, donc celui qui instancie le manager.
//
// L'ORDRE EST CRITIQUE : réveiller APRÈS s'être abonné. Si
// `bleStateSubject` est un PassthroughSubject, un état émis avant
// l'abonnement serait perdu, et on retomberait sur le même silence.
_ = listener.blePowered()
// La recherche ne se termine pas d'elle-même : on lui donne une
// fenêtre, puis on travaille avec ce qu'on a vu.
@@ -367,6 +380,21 @@ final class PolarPsFtpWriter {
}
}
/// Attend qu'une condition devienne vraie, en la sondant. Rend faux au bout
/// du délai.
///
/// Sonder plutôt qu'écouter : l'état du SDK se lit (`blePowered()`), et
/// c'est justement cette lecture qui instancie le CBCentralManager. Le
/// premier appel réveille, les suivants observent.
static func attendre(seconds: Double, _ condition: @escaping () -> Bool) async -> Bool {
let echeance = Date().addingTimeInterval(seconds)
while Date() < echeance {
if condition() { return true }
try? await Task.sleep(nanoseconds: 200_000_000)
}
return condition()
}
/// Court `operation` avec une échéance, et lève si elle est dépassée.
///
/// Le SDK Polar rend des `async` sans limite de temps : une montre qui ne