From 81582ce9691054ffe7213abfaaf44f6c0bce1898 Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Mon, 31 Aug 2026 13:17:08 +0000 Subject: [PATCH] =?UTF-8?q?Une=20attente=20sans=20fin=20ne=20dit=20rien=20?= =?UTF-8?q?:=20=C3=A9ch=C3=A9ances=20sur=20les=20appels=20du=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ios/App/App/CoachPolarBLE.swift | 39 ++++++++++++++++++- ios/App/App/PolarPftpStep.swift | 8 ++++ .../CoachModelTests/PolarPftpStepTests.swift | 24 ++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/ios/App/App/CoachPolarBLE.swift b/ios/App/App/CoachPolarBLE.swift index b7e4d40..645df34 100644 --- a/ios/App/App/CoachPolarBLE.swift +++ b/ios/App/App/CoachPolarBLE.swift @@ -248,7 +248,16 @@ final class PolarPsFtpWriter { continue } do { - try await client.waitPsFtpReady(true) + // ⚠️ `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,12 +335,38 @@ 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(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. - for try await _ in client.write(etape.header() as NSData, data: entree) {} + try await Self.avecEcheance(seconds: 20) { + for try await _ in client.write(etape.header() as NSData, data: entree) {} + } } } diff --git a/ios/App/App/PolarPftpStep.swift b/ios/App/App/PolarPftpStep.swift index 46efea4..99e8c0b 100644 --- a/ios/App/App/PolarPftpStep.swift +++ b/ios/App/App/PolarPftpStep.swift @@ -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 diff --git a/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift b/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift index 4398f63..1227b9c 100644 --- a/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift +++ b/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift @@ -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) + } +}