From 138d37c02f3cc0dd23f47a7d0d520c89728e4d47 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Tue, 14 Jul 2026 18:58:38 +0200 Subject: [PATCH] =?UTF-8?q?docs(incident):=20perte=20d'=C3=A9criture=20sur?= =?UTF-8?q?=20mort=20de=20socket=20(SerializationError)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-mortem 2026-07-14 (ouvert) : symptôme + preuves Firefox verbatim, chaîne causale tracée (socket→Disconnected→reconnexion en TODO), réserve (i) perte-écriture vs (ii) réhydratation à froid, repro @data décisive (test de reconnexion existant faux-vert = lecture IndexedDB locale). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg --- .../2026-07-14-write-loss-on-disconnect.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/incidents/2026-07-14-write-loss-on-disconnect.md diff --git a/docs/incidents/2026-07-14-write-loss-on-disconnect.md b/docs/incidents/2026-07-14-write-loss-on-disconnect.md new file mode 100644 index 0000000..b28d8e2 --- /dev/null +++ b/docs/incidents/2026-07-14-write-loss-on-disconnect.md @@ -0,0 +1,57 @@ +# Perte d'écriture lors d'une mort de socket (`SerializationError`) + +**Post-mortem — 2026-07-14 · Statut : OUVERT (non traité).** + +Une entité écrite juste avant une période d'inactivité peut être **perdue silencieusement** : l'écriture n'atteint jamais durablement le broker, et l'entité est absente à la reconnexion. Le **compte / l'identité survit** (pas de fork). Observé en conditions réelles (Festipod, Firefox) lors d'une pause après login/création. + +## Symptôme + +1. L'utilisateur se connecte, l'app crée une entité (un événement Festipod). +2. Une période d'inactivité suit (idle, onglet en arrière-plan…). +3. Le socket broker meurt spontanément avec `SOCKET IS CLOSED Some(Left(SerializationError))`. +4. À la reconnexion, l'entité créée a disparu ; l'app relit son propre scope **vide**. + +## Preuves (VÉRIFIÉ — logs Firefox en direct, verbatim) + +``` +… REPLAY TOPIC NOT FOUND IN OVERLAY +… NEED REPLAY true +… SENDING EVENTS FROM OUTBOX RETURNED: Err(TopicNotFound) +[user1][polyfill] resolveAccount(user1) → 1 record ← le compte SURVIT (pas de fork) +[user1][polyfill] readScopeIndex(…) → 0 entities ← mais le scope est VIDE +… set reçu: 0 objets Event (public) +… SOCKET IS CLOSED Some(Left(SerializationError)) [51, 3, 223, …] +``` + +Lecture (**mécanisme plausible, non tranché**) : l'écriture a été poussée dans l'**outbox** local, mais le socket est mort avant qu'elle ne soit **flushée durablement** dans le topic broker ; à la reconnexion, le replay de l'outbox échoue (`Err(TopicNotFound)`) parce que le topic n'a **jamais été créé côté broker** → l'événement est abandonné. Le compte, lui, avait déjà été résolu durablement (`resolveAccount → 1 record`) : il n'est ni perdu ni forké. + +> **Réserve épistémique.** Les preuves établissent le *symptôme* (perte + `Err(TopicNotFound)` + `readScopeIndex → 0`). Le *mécanisme* exact n'est pas tranché entre **(i) perte à l'écriture** (l'écriture n'atteint jamais durablement le broker) et **(ii) échec de réhydratation à froid** (l'écriture *est* sur le broker mais une session fraîche ne rouvre pas le scope propre). Le `Err(TopicNotFound)` sur le replay outbox penche pour **(i) dans ce cas Firefox**. Voir la repro @data ci-dessous, qui expose un symptôme voisin mais **ne tranche pas** (i) vs (ii). + +## Chaîne causale (TRACÉ — lecture du core NextGraph, à re-vérifier) + +- La `SerializationError` ferme le socket. Le core émet la déconnexion : `broker.rs` → `LocalBrokerMessage::Disconnected` → `disconnections_sender.send(...)` (≈ `broker.rs:1051`, à re-vérifier — numéro volatil, se repérer par le symbole). +- Cette déconnexion est **poussée** aux abonnés via `disconnections_subscribe(cb)` (flux PUSH). +- **La reconnexion NextGraph est un `// TODO` non implémenté** (≈ `broker.rs:1051-1076`) : rien ne rétablit le socket ni ne re-flushe l'outbox. +- `user_connect` renvoie un **instantané** `{ server_id, server_ip, error, since }` au moment de l'appel — pas un flux, inutilisable pour détecter une chute ultérieure. +- **Aucune API de confirmation de durabilité d'écriture** : un appelant ne peut pas `await` la garantie qu'une écriture a atteint le broker. + +## Ce que le SDK expose mais ne consomme pas + +`disconnections_subscribe` **se déclenche** sur cette panne — mais ni le polyfill (`@ng-eventually/client`) ni l'app consommateur ne s'y abonnent. Le signal existe, personne ne l'écoute ; côté app, aucun mécanisme ne re-tente ni n'avertit l'utilisateur. + +## Portée & non-reproduit + +- **Observé Firefox uniquement** à ce jour. Un test manuel sur un autre navigateur n'a pas déclenché la `SerializationError` ni ses conséquences. +- **Reproduction @data (Chromium, broker réel) — 2026-07-14, décisive.** Le test de reconnexion @data existant (`reconnexion-meme-identite`) était **faux-vert** : il relisait les repos de A depuis l'**IndexedDB local** du profil persistant, jamais depuis le broker. Un lecteur **réellement à froid** (contexte non-persistant `freshBrowser`, **même** wallet/compte A, aucun état local — seedé du wallet capturé avant l'événement) lit **0** événement de A (`BARRIER timed-out (8000ms)`, `CONNECTION ESTABLISHED`). Signature **différente** du cas Firefox (pas de mort de socket ; l'`OUTBOX empty` est celui du lecteur, trivialement vide) et **ne tranche pas** (i) vs (ii) — un barrier vide est compatible avec les deux. Établi en revanche : **@data n'a jamais vérifié la durabilité broker des lectures propres de A**, et la réhydratation à froid depuis le broker échoue. Repro : `src/modules/event/features/reconnexion-froide-sans-local.feature` (Festipod). +- **Pour trancher (i) vs (ii)** : vérifier indépendamment que l'écriture de A atteint le broker — p.ex. un lecteur *chaud* / une seconde identité lit le doc public de l'événement (le scénario d'isolation deux-identités). S'il le voit → l'écriture est durable → le 0 du lecteur à froid est un **(ii)** (réhydratation). Sinon → **(i)**. + +## Pistes de correction (non arbitré) + +1. **Core** — corriger la `SerializationError` **et** implémenter le TODO de reconnexion (rétablir le socket + re-flusher l'outbox). +2. **SDK / polyfill** — consommer `disconnections_subscribe` → reconnexion + re-flush outbox comme mitigation, indépendamment du core. +3. **API de durabilité** — exposer une confirmation qu'une écriture a atteint le broker, pour que l'appelant puisse l'`await`. + +## Liens + +- `docs/nextgraph-current-state.md` — état courant du core (déconnexion / reconnexion à cross-référencer ici). +- Impact produit + caveat côté consommateur : concept Festipod `data-layer` → `caveat_write-durability-across-disconnect`.