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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user