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