Files
coach-ios/docs/DIAGNOSTIC-NATIF-2026-09-24.md
Sylvain Bettinelli 8e0c2738ce Les cinq constats natifs de l'audit, vérifiés ligne à ligne — sans rien corriger
L'audit du 22/09 marquait cinq constats **[rapporté]** : issus de revues
automatisées, jamais reconfrontés au code, et il le disait lui-même — « les
confirmer avant d'agir ». Sylvain a autorisé la lecture, pas la modification :
aucune ligne de Swift ne bouge ici.

**Les cinq sont confirmés, aucun faux positif.** Trois lectures s'écartent
toutefois de ce que l'audit décrivait :

§ 1.2 est **plus grave**. L'audit ne voyait que le `try?` ; `pushToWatch` a
quatre sorties silencieuses sur sept lignes — WCSession non supportée, session
non activée, encodage échoué, envoi refusé. Et l'aggravant se vérifie :
`HeartRateZones` ne porte aucune date, alors que le backend sert une version
figée et datée (règle 9 du CLAUDE.md web). La date existe côté serveur et se
perd au transport. `asOf()` périme l'eau, les kcal, la routine et le score de
forme, mais pas les zones — délibérément, et c'est défendable pour une
péremption journalière ; ça laisse ouvert le cas d'une révision des bornes.

§ 2.3 est **plus nuancé**. Le code sait déjà que `finishWorkout()` rend nil sans
erreur quand la montre est verrouillée, et que la séance est alors bien écrite —
d'où `recentlySavedWorkout()`. Le défaut n'est donc pas « la séance est perdue »
mais que le double `try?` rend indiscernables deux situations opposées : montre
verrouillée (séance sauvée) et `endCollection` qui a levé (rien de sauvé). Les
deux tombent dans le même `else`.

§ 2.4 est **plus grave** aussi : `self.builder = nil` détruit la seule référence
au builder. La perte devient définitive dans l'instruction même qui l'ignore, et
le GPS continue de tourner après la fin apparente de la séance.

§ 1.3 et § 2.6 sont exacts tels que décrits. Pour § 2.6, un fait qui manquait :
le même fichier utilise `NSLock` pour ce motif à quatre autres endroits — c'est
une omission ponctuelle, pas une convention absente.

⚠️ Les numéros de ligne de l'audit ne valent plus : les correctifs du 22/09 au
soir ont décalé `WorkoutManager.swift` et `CoachHealthRoute.swift`. Le document
porte les références à jour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 08:00:30 +00:00

10 KiB

Diagnostic natif — les cinq constats ouverts de l'audit, vérifiés

24/09/2026. Lecture seule : aucune ligne de Swift n'a été modifiée. Demandé par Sylvain après l'audit du 22/09, dont cinq constats étaient marqués [rapporté] — issus de revues automatisées, jamais reconfrontés au code. L'audit lui-même le disait : « les confirmer avant d'agir ».

Ce document dit ce que le code fait aujourd'hui, ce que l'audit décrivait mal, et ce qu'il faudra corriger le jour où le gel sur le natif sera levé. Il ne propose aucun geste : la correction demande un build Mac, et rien n'est réel tant que ce build n'a pas tourné.

⚠️ Les numéros de ligne de l'audit ne valent plus. Les correctifs du 22/09 au soir ont décalé WorkoutManager.swift et CoachHealthRoute.swift. Les références ci-dessous sont celles du dépôt à 6031be8.


Verdict d'ensemble

Les cinq sont confirmés. Aucun faux positif. Deux sont plus graves que l'audit ne le disait, un est plus nuancé.

# Constat Vérifié Écart avec l'audit
1.2 Zones cardiaques muettes vers la montre ✅ Aggravé : 4 sorties muettes, pas 1
1.3 « Refusé » ≡ « aucune donnée » (sommeil) ✅ Exact
2.3 Séance perdue sans log ✅ Nuancé : nil n'est pas toujours une perte
2.4 didFailWithError laisse le GPS allumé ✅ Aggravé : builder = nil rend la récupération impossible
2.6 Tampon GPS sans verrou ✅ Exact

1.2 — Les zones cardiaques peuvent ne jamais atteindre la montre

ios/App/App/CoachWidgetBridge.swift:175-183 (pushToWatch)

L'audit ne signalait que le try?. Il y a quatre sorties silencieuses sur sept lignes :

guard WCSession.isSupported() else { return }              // 1. muet
let session = WCSession.default
guard session.activationState == .activated,               // 2. muet
      let data = try? JSONEncoder().encode(snapshot) else { return }  // 3. muet
try? session.updateApplicationContext(["widgetSnapshot": data])       // 4. avalé

Aucun log, aucune remontée au JS. Le snapshot porte zones: HeartRateZones? (CoachWidgetSnapshot.swift:96) — les bornes Z1…Z5 et clinicalTargetBpm, la cible sous bêtabloquant.

Ce que fait le chemin voisin, dans le même dépôt (CoachRoutineBridge.swift:87-101) pour la routine de gainage : il vérifie isPaired et isWatchAppInstalled avec une raison explicite, entoure l'appel d'un do/catch, journalise l'échec et appelle call.reject.

Le chemin instrumenté porte des exercices de mobilité. Le chemin muet porte les bornes cardiaques d'un patient post-infarctus.

L'aggravant est réel, et il se compose avec le premier

HeartRateZones (ios/App/App/HeartRateZones.swift) ne porte aucune date : ni version, ni validFrom, ni computedAt. Or le backend sert une version figée et datée (data/hr_zones_history.json, via app.current_zones()) — c'est même une règle non négociable du projet web (règle 9 du CLAUDE.md).

La date existe côté serveur et se perd au transport.

Et CoachWidgetSnapshot.asOf(_:) périme l'eau, le café, les kcal, la routine, l'activité du jour et le score de forme — mais pas zones, délibérément (« version figée, valable des semaines »). Le raisonnement est bon pour une péremption journalière ; il laisse ouvert le cas d'une révision des bornes.

Conséquence composée : après un POST /api/cardiac-zones/confirm, si le push échoue silencieusement, la montre affiche les anciennes bornes sans limite de temps et sans que rien ne le signale, ni au poignet ni à l'iPhone.

À corriger, le jour venu : aligner pushToWatch sur CoachRoutineBridge (do/catch, log, isPaired/isWatchAppInstalled), et faire voyager la date de version des zones pour que la montre puisse dire « bornes du 12/08 » plutôt que d'afficher un chiffre sans âge.


1.3 — « Accès refusé » et « aucune donnée » sont rendus identiques

ios/App/App/CoachSleep.swift — en-tête ligne 30 :

// r.available = false si HealthKit est indisponible ou non autorisé

Le code teste HKHealthStore.isHealthDataAvailable() (l. 59, 63) et la disponibilité du type (l. 68). authorizationStatus(for:) n'apparaît nulle part dans le fichier. La requête aboutit et rend :

call.resolve(["available": true, "samples": out, ...])   // l. 92-96

La documentation du fichier promet donc quelque chose que le code ne fait pas.

La nuance de l'audit est juste : HealthKit ne distingue pas volontairement « refusé » de « vide » en lecture — c'est une protection de vie privée documentée par Apple. Mais authorizationStatus(for:) renvoie .notDetermined de façon fiable, et c'est le cas fréquent : réinstallation de l'app, autorisation jamais accordée. C'est celui-là qu'on peut distinguer.

Conséquence côté serveur : sleep_score.py calcule une régularité de coucher sur des nuits absentes, sans savoir qu'elles sont absentes parce qu'on ne nous laisse plus regarder. Une valeur fausse, pas une valeur manquante.

Même famille dans CoachHealthRoute.swift (l. 173 et 277 selon l'audit — à revérifier, les lignes ont bougé).


2.3 — Une séance peut disparaître sans un seul log

ios/App/CoachWatch/WorkoutManager.swift:383-398

LocationTracker.shared.stopTracking()
try? await builder.endCollection(at: date)
let workout = try? await builder.finishWorkout()
if let workout { … } else if let recovered = await self.recentlySavedWorkout() { … }
else { LocationTracker.shared.discardRoute() }

Ce que l'audit ne disait pas, et qui change la lecture : le commentaire du code explique que finishWorkout() rend nil sans erreur quand la montre est verrouillée, et que dans ce cas la séance est écrite dans HealthKit — seul l'objet manque. C'est pour ça que recentlySavedWorkout() existe. Un nil n'est donc pas une perte en soi, et le code le sait.

Le défaut est plus précis que « la séance est perdue » : le double try? efface la différence entre deux situations opposées —

Ce qui s'est passé workout La séance est-elle sauvée ?
Montre verrouillée à l'arrêt nil Oui, recentlySavedWorkout() la retrouve
endCollection a levé nil Non, il n'y a rien à retrouver

Les deux chemins convergent vers le même else, et discardRoute() ne journalise que la perte de la trace. statusMessage n'est pas touché : après deux heures de sortie, l'utilisateur voit l'écran de fin habituel.

À corriger : remplacer les deux try? par un do/catch qui journalise l'erreur — sans changer la logique de récupération, qui est bonne.


2.4 — didFailWithError ne clôt rien et laisse le GPS allumé

ios/App/CoachWatch/WorkoutManager.swift:451-464

workoutLog.error("session didFailWithError domain=… code=…: \(message)")
Task { @MainActor in
    self.statusMessage = "Erreur séance : \(message)"
    self.isRunning = false
    self.session = nil
    self.builder = nil
    ConnectivityManager.shared.sendEnded(reason: "session-failed")
}

Comparé au chemin .ended (l. 383-398), il manque tout : stopTracking(), endCollection, finishWorkout, finishRoute/discardRoute.

Plus grave que l'audit ne le dit : self.builder = nil détruit la seule référence au builder. Après cette ligne, plus aucun code ne peut tenter de sauver ce qu'il avait collecté — la perte devient définitive dans la même instruction qui l'ignore.

Deux conséquences distinctes :

  1. Le GPS continue de tourner. LocationTracker n'est jamais arrêté, alors que la séance est finie du point de vue de l'interface. Sur une montre, ça se paie en batterie, et rien ne l'indique.
  2. Les données collectées sont jetées sans tentative de sauvegarde.

⚠️ À la différence de § 2.3, ici statusMessage est renseigné : l'utilisateur voit « Erreur séance ». Il sait qu'il s'est passé quelque chose ; il ne sait pas qu'il a perdu la séance, et le GPS tourne toujours.


2.6 — Accès concurrent non protégé sur le tampon GPS

ios/App/App/CoachHealthRoute.swift:183-205 (collectLocations)

var all: [CLLocation] = []
let group = DispatchGroup()
var pluginErr: Error?
for route in routes {
    group.enter()
    let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
        if let err = err { pluginErr = err; group.leave(); return }
        if let locs = locations { all.append(contentsOf: locs) }   // ⚠️ sans verrou
        …

all et pluginErr sont mutés depuis les callbacks de N requêtes, livrées sur des files HealthKit indépendantes. Aucun verrou.

Le même fichier utilise NSLock pour ce motif exact à quatre endroits : lignes 283, 380, 442 et 565. Ce n'est donc pas une convention absente du projet — c'est une omission à un seul endroit.

Déclencheur : un workout portant plusieurs HKWorkoutRoute (multisport, ou reprise après interruption). Avec une seule route, la boucle n'a qu'une itération et le défaut est inobservable.

⚠️ On ne sait pas s'il s'est déjà produit : les routes vivent dans HealthKit, pas sur le serveur, et rien ne journalise routes.count. Une corruption de tableau en Swift ne lève pas forcément — elle peut donner une trace tronquée ou un crash, sans rien qui désigne la cause.


Ce que ce diagnostic ne dit pas

  • Aucune de ces corrections n'a été écrite. Le gel du 22/09 sur le natif tient ; Sylvain a autorisé la lecture, pas la modification.
  • Rien n'est vérifiable sans build Mac. Ces cinq constats sont établis par lecture du code, pas par exécution. Une lecture peut manquer un chemin.
  • La dette assumée du 22/09 reste ouverte : le defer { completion() } de CoachWorkoutObserver déclare la livraison traitée alors que les requêtes sont encore en vol, sans beginBackgroundTask. Elle n'est pas revue ici, elle était déjà documentée et assumée.
  • Le § 4 de l'audit (gravité 3, justesse et cohérence) n'a pas été revérifié.

Si un seul devait être corrigé

§ 1.2, et pas parce qu'il est le plus spectaculaire : parce que c'est le seul dont la conséquence est un chiffre faux affiché pendant l'effort, chez quelqu'un qui règle son intensité dessus. Les quatre autres perdent de la donnée ou de la batterie — c'est réparable après coup. Une borne de zone périmée au poignet ne se rattrape pas : elle a déjà servi à décider.