Une attente sans fin ne dit rien : échéances sur les appels du SDK

L'envoi tournait sans jamais rendre la main, ni message ni erreur. Cause : les
`async` du SDK Polar n'ont aucune limite de temps, et `waitPsFtpReady` attend
indéfiniment une montre qui ne finit pas sa négociation — canal déjà pris, écran
éteint, appairage en cours.

Une attente sans fin est pire qu'un échec : elle n'apprend rien, et c'est
exactement ce qu'on cherche à éviter depuis ce matin. `waitPsFtpReady` a
désormais 12 s, chaque écriture 20 s, et un dépassement compte comme une montre
muette — c'est ce qu'elle est. Le message dit quoi faire : réveiller l'écran,
rapprocher, fermer Flow.

Le fait qu'on soit arrivé jusqu'à ce blocage est en soi une information : avant
le réveil du manager lazy, on n'atteignait même pas la phase de connexion.

82 tests au vert sur tests-linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-31 13:17:08 +00:00
parent 1389edb1f7
commit 81582ce969
3 changed files with 69 additions and 2 deletions

View File

@@ -248,7 +248,16 @@ final class PolarPsFtpWriter {
continue
}
do {
// `waitPsFtpReady` n'a AUCUNE limite de temps. Si la
// montre ne finit pas la négociation canal déjà pris,
// écran éteint, appairage en cours l'attente ne rend
// jamais la main et l'envoi paraît figé, sans message.
// Constaté le 2026-08-31 : « envoi » tournant sans fin.
// On lui donne donc une échéance, et un dépassement compte
// comme une montre muette : c'est ce qu'il est.
try await Self.avecEcheance(seconds: 12) {
try await client.waitPsFtpReady(true)
}
return (client, session)
} catch {
muettes += 1
@@ -326,13 +335,39 @@ final class PolarPsFtpWriter {
}
}
/// 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
/// répond pas fige l'appel indéfiniment. Un envoi qui n'aboutit pas doit
/// dire pourquoi, pas tourner en silence.
static func avecEcheance<T: Sendable>(seconds: Double,
_ operation: @escaping @Sendable () async throws -> T)
async throws -> T {
try await withThrowingTaskGroup(of: T.self) { groupe in
groupe.addTask { try await operation() }
groupe.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw PolarPftpError.echeanceDepassee(seconds)
}
guard let premier = try await groupe.next() else {
throw PolarPftpError.echeanceDepassee(seconds)
}
groupe.cancelAll()
return premier
}
}
private func put(_ client: BlePsFtpClient, _ etape: PftpStep) async throws {
let entree = InputStream(data: etape.data)
// Même raison que pour waitPsFtpReady : une écriture qui n'aboutit pas
// doit échouer, pas figer l'app.
// `write` rend un flux de progression ; on le consomme jusqu'au bout,
// et l'absence d'erreur vaut acquittement c'est l'équivalent BLE du
// `05 00 00` observé en USB.
try await Self.avecEcheance(seconds: 20) {
for try await _ in client.write(etape.header() as NSData, data: entree) {}
}
}
}
#endif

View File

@@ -109,6 +109,8 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable {
/// Le SDK n'a remonté aucune session, mais CoreBluetooth, interrogé
/// directement, a vu `vusParCoreBluetooth` appareils. Deux diagnostics
/// opposés selon ce nombre.
/// Une opération du SDK n'a pas rendu la main dans le délai imparti.
case echeanceDepassee(Double)
case sdkSilencieux(vusParCoreBluetooth: Int,
publisherAParle: Bool = true,
erreurSdk: String? = nil)
@@ -125,6 +127,12 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable {
case .bluetoothUnusable(let why):
return why
case .echeanceDepassee(let seconds):
return "la montre n'a pas répondu en \(Int(seconds)) s. Réveiller son "
+ "écran et la rapprocher de l'iPhone ; vérifier que Polar Flow "
+ "est bien fermée."
case .sdkSilencieux(_, false, _):
// Le publisher n'a JAMAIS rien émis : `search()` s'arrête sur
// `monitorBleState().filter { $0 == .poweredOn }`. Le scan n'a donc

View File

@@ -234,3 +234,27 @@ extension PolarPftpErrorTests {
XCTAssertTrue(e.description.contains("intégration du SDK"))
}
}
// MARK: - Un envoi qui n'aboutit pas doit le dire
//
// Les `async` du SDK Polar n'ont aucune limite de temps. Le 2026-08-31, un envoi
// est resté figé sans message : `waitPsFtpReady` attendait une montre qui ne
// finissait pas la négociation. Une attente sans fin est pire qu'une erreur
// elle ne dit rien et n'apprend rien.
extension PolarPftpErrorTests {
func testLEcheanceDitQuoiFaire() {
let e = PolarPftpError.echeanceDepassee(12)
XCTAssertTrue(e.description.contains("12"))
XCTAssertTrue(e.description.contains("écran"))
XCTAssertTrue(e.description.contains("Polar Flow"))
}
func testLEcheanceNeSeConfondPasAvecUnScanVide() {
let echeance = PolarPftpError.echeanceDepassee(12).description
let vide = PolarPftpError.watchNotFound(30, vues: 0,
sansPsFtp: 0, muettes: 0).description
XCTAssertNotEqual(echeance, vide)
}
}