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 +64 -8
- package/docs/HOST-CONTRACT.md +24 -0
- package/package.json +1 -1
- package/server/cache.js +21 -1
- package/server/handler.js +24 -0
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 };
|
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -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.
|
|
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)
|
|
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
|