discovery-media-player 0.1.138 → 0.1.139

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/serve.js CHANGED
@@ -185,13 +185,69 @@ function lireCorpsJson(req, maxOctets = 1_000_000) {
185
185
  });
186
186
  }
187
187
 
188
+ /**
189
+ * ⚠️ AUCUN SIGNAL N'ÉTAIT ÉCOUTÉ, ET LE CHOIX ÉTAIT ENTRE LENT ET BRUTAL. Sans gestionnaire, Node
190
+ * PID 1 IGNORE `SIGTERM` (le noyau ne délivre pas à PID 1 un signal sans gestionnaire) : `docker
191
+ * stop` attend dix secondes puis tue. C'est ce que `dumb-init` corrige — il est PID 1, Node est son
192
+ * ENFANT, donc le signal relayé y déclenche l'action par défaut : terminaison IMMÉDIATE. Rapide,
193
+ * mais net : un document en cours de relais, une lecture de présentation, un battement — tranchés
194
+ * au milieu, à CHAQUE déploiement. La troisième voie n'avait jamais été posée.
195
+ *
196
+ * Ici : on cesse d'accepter, on laisse finir ce qui est en vol, on sort. Avec ou sans `dumb-init`.
197
+ *
198
+ * ⚠️ PREMIER PIÈGE — `close()` SEUL N'ARRIVE JAMAIS AU BOUT. Il attend que TOUTES les connexions se
199
+ * ferment, or le keep-alive en garde d'oisives ouvertes plusieurs secondes après leur dernière
200
+ * requête. Un arrêt qui attend ces sockets-là dépasse le délai de l'orchestrateur et se fait tuer :
201
+ * on aurait remplacé un arrêt brutal par un arrêt brutal PLUS LENT. `closeIdleConnections()` ferme
202
+ * ce qui ne sert plus, sans toucher à ce qui travaille.
203
+ *
204
+ * ⚠️ SECOND PIÈGE — SANS ÉCHÉANCE, UNE SEULE REQUÊTE BLOQUÉE TIENT TOUT. Un relais vers un stockage
205
+ * qui ne répond plus n'a aucune raison de finir. Le délai est donc BORNÉ, et volontairement sous le
206
+ * défaut de `docker stop` (dix secondes) : une échéance qui tombe après le couperet ne sert à rien.
207
+ * Passé le délai on coupe ce qui reste — c'est exactement l'ancien comportement, mais seulement pour
208
+ * ce qui n'a pas su finir, et après l'avoir DIT.
209
+ *
210
+ * ⚠️ Le minuteur est `unref()` : un compte à rebours qui empêcherait le processus de sortir
211
+ * retiendrait précisément l'arrêt qu'il surveille.
212
+ */
213
+ const DELAI_ARRET_MS = Math.max(0, Number(process.env.PLAYER_SHUTDOWN_GRACE_MS) || 0) || 8000;
214
+
215
+ function arreterProprement(signal) {
216
+ // ⚠️ UN SECOND SIGNAL SORT TOUT DE SUITE. Qui appuie deux fois sur Ctrl-C demande l'arrêt, pas
217
+ // une explication — et un gestionnaire qui insiste après un second ordre est un processus qu'on
218
+ // finit par tuer à la main.
219
+ if (arreterProprement.enCours) { process.exit(130); return; }
220
+ arreterProprement.enCours = true;
221
+ console.log(`${signal} reçu — arrêt : plus de nouvelle connexion, ${DELAI_ARRET_MS} ms pour finir ce qui est en vol`);
222
+
223
+ const couperet = setTimeout(() => {
224
+ console.warn(`arrêt : délai de ${DELAI_ARRET_MS} ms dépassé, les requêtes encore en vol sont coupées`);
225
+ serveur.closeAllConnections();
226
+ process.exit(1);
227
+ }, DELAI_ARRET_MS);
228
+ couperet.unref();
229
+
230
+ serveur.close(() => { clearTimeout(couperet); console.log("arrêt : tout est fini, sortie propre"); process.exit(0); });
231
+ serveur.closeIdleConnections();
232
+ }
233
+
188
234
  // N'écoute QUE lorsqu'on lance ce fichier. Sans cette garde, un test qui l'importe ouvrirait un
189
- // port — et deux tests en parallèle s'attraperaient sur le même.
190
- if (require.main === module) serveur.listen(PORT, HOST, () => {
191
- const racine = process.env.PLAYER_LOCAL_ROOT;
192
- console.log(`Discovery Media Player — http://localhost:${PORT}`);
193
- console.log(racine ? ` documents : ${racine}` : " documents : aucun dossier local (PLAYER_LOCAL_ROOT)");
194
- console.log(` état : http://localhost:${PORT}/api/doc?contract=1`);
195
- });
235
+ // port — et deux tests en parallèle s'attraperaient sur le même. Les signaux suivent la même règle,
236
+ // et pour une raison de plus : un gestionnaire posé par un IMPORT vit dans le processus de qui
237
+ // importe, et détournerait le Ctrl-C d'un banc qui ne demandait qu'à lire une fonction.
238
+ if (require.main === module) {
239
+ for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => arreterProprement(signal));
240
+ serveur.listen(PORT, HOST, () => {
241
+ const racine = process.env.PLAYER_LOCAL_ROOT;
242
+ // ⚠️ LE PORT OBTENU, PAS LE PORT DEMANDÉ. `PORT=0` demande à l'OS d'en choisir un libre — la
243
+ // ligne affichait alors « localhost:0 », une adresse qui ne mène nulle part, au moment précis
244
+ // où l'on a besoin de savoir où frapper. Une trace de démarrage qui n'aide pas à joindre le
245
+ // serveur ne sert à rien.
246
+ const ouvert = (serveur.address() || {}).port || PORT;
247
+ console.log(`Discovery Media Player — http://localhost:${ouvert}`);
248
+ console.log(racine ? ` documents : ${racine}` : " documents : aucun dossier local (PLAYER_LOCAL_ROOT)");
249
+ console.log(` état : http://localhost:${ouvert}/api/doc?contract=1`);
250
+ });
251
+ }
196
252
 
197
- module.exports = { serveur, versParametres, pageAccueil };
253
+ module.exports = { serveur, versParametres, pageAccueil, __arreterProprement: arreterProprement, DELAI_ARRET_MS };
@@ -40,6 +40,7 @@ need.
40
40
  "presenceJetons": true,
41
41
  "presenceDurcissement": "inconnu",
42
42
  "presenceFusion": "inconnu",
43
+ "lectureSaturee": { "total": 0, "fenetreS": 0, "derniereIlYaS": null },
43
44
  "retentionSweep": false,
44
45
  "hostShare": true,
45
46
  "hostMail": true,
@@ -91,6 +92,29 @@ The three `presence*` fields report what the host has **observed**, not what it
91
92
  | `presenceDurcissement` | `actif` (a hardened call came back), `degrade` (migration 0018 is missing), `inconnu` (nothing attempted in this process — **not** a green light, and process-local: another instance may have seen otherwise) |
92
93
  | `presenceFusion` | `actif` (a heartbeat used the fused contract — one round trip instead of two), `degrade` (migration 0019 is missing: heartbeats cost 3 round trips instead of 2, nothing breaks), `inconnu` (no heartbeat served in this process). Same three states, same trap, same reading rule as the row above |
93
94
 
95
+ ### `lectureSaturee` — what this instance actually refused
96
+
97
+ The read cache groups concurrent requests for the same presentation state and admits a bounded
98
+ number of them in flight. Past that ceiling it answers **`503` with `Retry-After: 1`** — a refusal,
99
+ not a failure, and deliberately distinguishable from a `500`. That ceiling has existed for a long
100
+ time; **nothing counted how often it was reached**, so the question *"do we actually saturate?"* had
101
+ no observable answer.
102
+
103
+ | key | meaning |
104
+ |---|---|
105
+ | `total` | refusals since this process started |
106
+ | `fenetreS` | how long this process has been running, in seconds — **the window `total` was counted over** |
107
+ | `derniereIlYaS` | seconds since the most recent refusal, or `null` if there has been none |
108
+
109
+ ⚠️ **`total` and `fenetreS` only mean anything together.** `total: 0` does not say *we do not
110
+ saturate*; on a process that started four seconds ago it says *nobody has looked yet*. That is the
111
+ same trap as `inconnu` in the two rows above, and the reason the three keys are returned as one
112
+ object rather than as separate fields you could read apart.
113
+
114
+ ⚠️ **It is process-local.** Behind a load balancer this is the count of the instance that answered,
115
+ not of your deployment. Aggregating is your job — and letting you believe otherwise would be worse
116
+ than returning nothing.
117
+
94
118
  ⚠️ **Before you upgrade, do not read `presenceDurcissement` or `presenceFusion`.** They are *reports
95
119
  of execution*: on an instance where nothing is running they say `inconnu`, which means *nobody
96
120
  looked* — not *the migration is there*. A pre-flight check built on one of them silently passes on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.138",
3
+ "version": "0.1.139",
4
4
  "description": "Self-hosted document viewer: per-recipient tracked links, reading analytics, live presentation. The core knows nothing about the application hosting it — everything it borrows arrives through an injected context.",
5
5
  "keywords": [
6
6
  "pdf-viewer",
package/server/cache.js CHANGED
@@ -75,6 +75,12 @@ function creerCache(options) {
75
75
  // résultat en mémoire.
76
76
  const maxEnVol = Math.max(1, Number(options && options.maxEnVol) || 128);
77
77
  const now = (options && options.now) || (() => Date.now());
78
+ // ⚠️ CE QUI EST REFUSÉ NE LAISSAIT AUCUNE TRACE. Le plafond d'admission existe depuis longtemps
79
+ // et il rend un 503 propre — mais rien ne comptait combien de fois il avait servi. La question
80
+ // « sature-t-on, en vrai ? » n'avait donc AUCUNE réponse observable, et c'est précisément celle
81
+ // dont dépend la décision d'optimiser ou non le chemin chaud. Optimiser sans elle, c'est deviner.
82
+ let nSatures = 0;
83
+ let dernierSature = null;
78
84
  /** @type {Map<string, { echeance: number, promesse: Promise<unknown>, poids: number, enVol: boolean, rendrePlace?: () => void }>} */
79
85
  const entrees = new Map();
80
86
  let poidsTotal = 0;
@@ -123,7 +129,11 @@ function creerCache(options) {
123
129
  // existe pour absorber. On ne refuse que ce qui coûterait une requête DE PLUS.
124
130
  if (vue && (vue.enVol || vue.echeance > t)) return vue.promesse;
125
131
 
126
- if (nEnVol >= maxEnVol) throw erreurSaturation(maxEnVol);
132
+ if (nEnVol >= maxEnVol) {
133
+ nSatures += 1;
134
+ dernierSature = t;
135
+ throw erreurSaturation(maxEnVol);
136
+ }
127
137
 
128
138
  const promesse = Promise.resolve().then(produire);
129
139
  // ⚠️ UN DÉCOMPTE IDEMPOTENT PAR PROMESSE. Deux chemins libèrent la place (résolution, et
@@ -172,6 +182,16 @@ function creerCache(options) {
172
182
  poids: () => poidsTotal,
173
183
  /** Demandes actuellement en vol — ce que le plafond d'admission borne. */
174
184
  enVol: () => nEnVol,
185
+ /**
186
+ * Ce que le plafond a refusé depuis le démarrage de ce processus.
187
+ *
188
+ * ⚠️ UN TOTAL SEUL MENT PAR OMISSION, et c'est pour ça que `dernier` l'accompagne. « 0 refus »
189
+ * ne veut pas dire « on ne sature pas » : ça peut vouloir dire « ce processus vient de
190
+ * démarrer ». La même règle que `presenceFusion` dans la carte — un rapport d'exécution n'est
191
+ * pas un inventaire, et une absence d'observation n'est pas une preuve. Celui qui LIT reste
192
+ * responsable de savoir depuis quand ce processus regarde ; la carte, elle, le lui dit.
193
+ */
194
+ satures: () => ({ total: nSatures, dernier: dernierSature }),
175
195
  };
176
196
  }
177
197
 
package/server/handler.js CHANGED
@@ -694,6 +694,30 @@ async function handler(req, res) {
694
694
  return typeof signer === "function" && !!signer("carte-sonde", "carte-sonde", 60);
695
695
  } catch { return false; }
696
696
  })(),
697
+ // ⚠️ CE QUE LE PLAFOND D'ADMISSION A REFUSÉ — la seule question dont dépend la décision
698
+ // d'optimiser le chemin chaud, et elle n'avait aucune réponse observable. Le cache de
699
+ // lecture refuse une demande de trop par un 503 réessayable depuis longtemps ; rien ne
700
+ // comptait combien de fois. « Faut-il fusionner les opérations du battement ? » se
701
+ // tranchait donc au flair (audit CODEX 5.6, §2 — qui disait lui-même d'attendre une mesure).
702
+ //
703
+ // ⚠️ ET `fenetreS` N'EST PAS UN ORNEMENT : SANS ELLE LE TOTAL MENT PAR OMISSION. « 0 refus »
704
+ // ne veut pas dire « on ne sature pas » — ça peut vouloir dire « ce processus a démarré il y
705
+ // a quatre secondes ». Le total et la fenêtre qui l'a produit ne se lisent QUE ensemble,
706
+ // exactement comme `presenceFusion` ne se lit pas sans savoir que `inconnu` veut dire
707
+ // « personne n'a regardé ». On les rend donc dans le même objet, jamais séparés.
708
+ //
709
+ // ⚠️ PROCESSUS-LOCAL, comme les champs de présence. Une instance qui répond n'est pas
710
+ // toutes les instances : derrière un répartiteur, ce compte est celui de CELLE qui a
711
+ // répondu. Agréger est le travail de l'hôte, et lui laisser croire l'inverse serait pire
712
+ // que de ne rien rendre.
713
+ lectureSaturee: (() => {
714
+ const { total, dernier } = cacheLecture.satures();
715
+ return {
716
+ total,
717
+ fenetreS: Math.round(process.uptime()),
718
+ derniereIlYaS: dernier == null ? null : Math.max(0, Math.round((Date.now() - dernier) / 1000)),
719
+ };
720
+ })(),
697
721
  // ⚠️ LE BALAYAGE DE RÉTENTION EST-IL ARMÉ ? La capacité `retention` dit que l'instance PEUT
698
722
  // purger ; ce booléen dit si le balayage automatique TOURNE (`config.retention.balayage`).
699
723
  // Sans lui, une instance armée est indiscernable d'une instance éteinte — et une purge qui