blePowered() lit le manager, search() lit le sujet : ce ne sont pas les mêmes

Le message n'avait pas bougé alors qu'il n'aurait plus dû pouvoir apparaître :
attendre `blePowered()` avait donc réussi, et le publisher se taisait quand même.
C'est ce qui a montré l'erreur.

`blePowered()` rend `manager.state == .poweredOn` — l'état de CoreBluetooth. Or
`search()` filtre sur `bleStateSubject`, alimenté uniquement par
`centralManagerDidUpdateState`. Le manager peut donc être allumé sans que le
délégué ait publié quoi que ce soit : le sujet reste à `.unknown`, le filtre
bloque, le publisher se tait. Attendre `blePowered()` ne prouvait rien sur ce que
`search()` allait voir — c'était la mauvaise sonde.

On attend désormais sur `monitorBleState()`, qui est public et qui EST la source
lue par search(). `blePowered()` garde un seul rôle : instancier la lazy pour
que le délégué puisse tourner.

L'attente porte son échéance elle-même plutôt que de passer par avecEcheance :
le listener n'est pas Sendable et le faire traverser une TaskGroup se heurterait
à la concurrence stricte de Swift 6. Et `BoiteJeton` garantit une reprise unique
de la continuation — deux chemins de sortie concurrents (l'état publié et
l'échéance), et reprendre deux fois est un crash, pas un avertissement.

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:31:30 +00:00
parent 753d7b248e
commit eef76daa45
2 changed files with 79 additions and 13 deletions

View File

@@ -182,10 +182,18 @@ piège suivant.
depuis le délégué. Si CoreBluetooth appelle le délégué avant que l'initialisation 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. de la lazy soit terminée, la propriété se réentre.
**Séquence correcte** : créer le listener, poser `scanPreFilter`, réveiller la **3. `blePowered()` et `search()` ne lisent pas la même chose.** `blePowered()`
lazy en sondant `blePowered()` jusqu'à ce qu'elle réponde vrai — hors de tout rend `manager.state == .poweredOn` — l'état de CoreBluetooth. `search()` filtre
abonnement — et seulement ensuite appeler `search()`, une fois, sur toute la sur `bleStateSubject`, qui n'est alimenté que par `centralManagerDidUpdateState`.
durée. Un abonnement par tranche relancerait le cycle Le manager peut donc être allumé sans que le délégué ait publié quoi que ce
soit : le sujet reste à `.unknown` et le filtre bloque. Attendre `blePowered()`
ne prouve rien sur ce que `search()` verra.
**Séquence correcte** : créer le listener, poser `scanPreFilter`, appeler
`blePowered()` **une fois** (son seul rôle : instancier la lazy pour que le
délégué puisse tourner), puis attendre sur **`monitorBleState()`** — public, et
c'est la source que `search()` lit — jusqu'à `.poweredOn`. Seulement ensuite,
`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 `addClient()`/`removeClient()` du scanner et l'empêcherait de découvrir quoi que
ce soit. ce soit.

View File

@@ -252,16 +252,26 @@ final class PolarPsFtpWriter {
// réentre. D'où : réveiller tôt, hors de tout abonnement, et laisser // réentre. D'où : réveiller tôt, hors de tout abonnement, et laisser
// l'état se poser. // l'état se poser.
// //
// On attend donc que `blePowered()` réponde vrai ce qui signifie que // ET SURTOUT : attendre le SUJET, pas l'état du manager.
// le délégué a tourné et que `.poweredOn` est dans le sujet avant //
// d'appeler `search()`. // `blePowered()` lit `manager.state` l'état de CoreBluetooth. Mais
let pret = await Self.attendre(seconds: min(timeout, 10)) { // `search()` filtre sur `bleStateSubject`, qui n'est alimenté que par
listener.blePowered() // `centralManagerDidUpdateState`. Le manager peut donc être allumé
} // (`blePowered()` vrai) sans que le délégué ait encore publié quoi que
if !pret { // ce soit : le sujet reste à `.unknown`, le filtre bloque, et le
// publisher se tait. C'est exactement ce qui a été mesuré le
// 2026-08-31, y compris après avoir attendu `blePowered()`.
//
// On attend donc sur `monitorBleState()`, qui est public et qui EST la
// source lue par `search()`. `blePowered()` ne sert plus qu'à une
// chose : instancier la lazy pour que le délégué puisse tourner.
_ = listener.blePowered()
let etat = await listener.premierEtatPret(timeout: min(timeout, 12))
if !etat {
throw PolarPftpError.bluetoothUnusable( throw PolarPftpError.bluetoothUnusable(
"le listener du SDK Polar n'a pas atteint l'état « prêt ». " "le listener du SDK Polar n'a jamais publié l'état « prêt » "
+ "Bluetooth actif mais inutilisable par le SDK.") + "(son délégué n'a pas été appelé). Bluetooth actif côté "
+ "système, mais inutilisable par le SDK.")
} }
let (client, session) = try await trouverClient(listener, timeout: timeout, let (client, session) = try await trouverClient(listener, timeout: timeout,
@@ -383,6 +393,9 @@ final class PolarPsFtpWriter {
/// Attend qu'une condition devienne vraie, en la sondant. Rend faux au bout /// Attend qu'une condition devienne vraie, en la sondant. Rend faux au bout
/// du délai. /// du délai.
/// ///
/// Conservé pour d'autres usages, mais NE PAS s'en servir pour l'état
/// BLE : `blePowered()` lit le manager, pas le sujet que `search()` écoute.
///
/// Sonder plutôt qu'écouter : l'état du SDK se lit (`blePowered()`), et /// Sonder plutôt qu'écouter : l'état du SDK se lit (`blePowered()`), et
/// c'est justement cette lecture qui instancie le CBCentralManager. Le /// c'est justement cette lecture qui instancie le CBCentralManager. Le
/// premier appel réveille, les suivants observent. /// premier appel réveille, les suivants observent.
@@ -509,3 +522,48 @@ private final class SondeBluetooth: NSObject, CBCentralManagerDelegate {
if central.state != .unknown { repondre(central.state) } if central.state != .unknown { repondre(central.state) }
} }
} }
extension CBDeviceListenerImpl {
/// Vrai dès que le listener PUBLIE l'état `.poweredOn` c'est-à-dire dès
/// que `search()` pourra franchir son filtre.
///
/// `monitorBleState()` rend un `CurrentValueSubject`, donc l'abonnement
/// reçoit immédiatement la valeur courante (`.unknown` au départ) puis les
/// suivantes. On attend la première qui vaut `.poweredOn`.
/// Porte son échéance elle-même plutôt que d'être enveloppée : le listener
/// n'est pas `Sendable`, et le faire traverser une TaskGroup se heurterait
/// à la concurrence stricte de Swift 6.
func premierEtatPret(timeout: Double) async -> Bool {
await withCheckedContinuation { suite in
let boite = BoiteJeton()
boite.jeton = monitorBleState()
.sink(receiveCompletion: { _ in boite.rendre(false, suite) },
receiveValue: { etat in
if etat == .poweredOn { boite.rendre(true, suite) }
})
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
boite.rendre(false, suite)
}
}
}
}
/// Garde l'abonnement en vie et garantit une reprise unique de la continuation
/// la reprendre deux fois est un crash, et il y a ici deux chemins de sortie
/// concurrents : l'état publié et l'échéance.
private final class BoiteJeton: @unchecked Sendable {
var jeton: AnyCancellable?
private var rendu = false
private let verrou = NSLock()
func rendre(_ valeur: Bool, _ suite: CheckedContinuation<Bool, Never>) {
verrou.lock()
defer { verrou.unlock() }
guard !rendu else { return }
rendu = true
suite.resume(returning: valeur)
jeton?.cancel()
jeton = nil
}
}