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

@@ -252,16 +252,26 @@ final class PolarPsFtpWriter {
// 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 {
// ET SURTOUT : attendre le SUJET, pas l'état du manager.
//
// `blePowered()` lit `manager.state` l'état de CoreBluetooth. Mais
// `search()` filtre sur `bleStateSubject`, qui n'est alimenté que par
// `centralManagerDidUpdateState`. Le manager peut donc être allumé
// (`blePowered()` vrai) sans que le délégué ait encore publié quoi que
// 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(
"le listener du SDK Polar n'a pas atteint l'état « prêt ». "
+ "Bluetooth actif mais inutilisable par le SDK.")
"le listener du SDK Polar n'a jamais publié l'état « prêt » "
+ "(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,
@@ -383,6 +393,9 @@ final class PolarPsFtpWriter {
/// Attend qu'une condition devienne vraie, en la sondant. Rend faux au bout
/// 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
/// c'est justement cette lecture qui instancie le CBCentralManager. Le
/// premier appel réveille, les suivants observent.
@@ -509,3 +522,48 @@ private final class SondeBluetooth: NSObject, CBCentralManagerDelegate {
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
}
}