[FIX] electron: lock liveness only when certain, retries on every write
A lock judged by its pid alone took a reused pid for a live session after a reboot, and the event stayed locked. The lock now records the process start time; a session is dead only when that is certain — no process with the pid, or two known start times that differ — and alive only when they match. Otherwise liveness is unknown and the session is not taken over, so two sessions never write at once (§ 8.8). The pause-and-retry on EPERM, EBUSY and EACCES that guarded only the rename now guards appends, moves and deletes too, through one helper. Checked: 1538 node, 26 node-long tests; the macOS start time is read in UTC. --- FR --- [FIX] electron : vie du verrou sûre, réessais sur chaque écriture Un verrou jugé sur son seul pid prenait un pid réattribué pour une séance vivante après un redémarrage, et l'événement restait verrouillé. Le verrou inscrit désormais l'heure de démarrage du processus ; une séance n'est morte que si c'est certain — aucun processus de ce pid, ou deux heures connues qui diffèrent — et vivante que si elles concordent. Sinon sa vie est inconnue et la séance n'est pas reprise : deux séances n'écrivent jamais ensemble (§ 8.8). La pause et les réessais sur EPERM, EBUSY et EACCES, qui ne gardaient que le renommage, gardent aussi ajouts, déplacements et suppressions, par une seule aide. Vérifié : 1538 node, 26 node-long ; l'heure de démarrage macOS se lit en UTC. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
c72215a2da
commit
8d03d5ba41
5 changed files with 561 additions and 50 deletions
|
|
@ -75,13 +75,15 @@ export const TEMOIN = '.gtt-temoin';
|
|||
const TEXTE_TEMOIN = 'T\u{E9}moin d\u{2019}\u{E9}criture de Gestion table tournante Libre.\n';
|
||||
|
||||
/**
|
||||
* Le renommage de l'écriture atomique se réessaie, après son premier essai,
|
||||
* REESSAIS_RENOMMAGE fois sur une cause passagère : un antivirus ou un agent
|
||||
* de synchronisation qui tient la cible ouverte sous Windows (EPERM, EBUSY,
|
||||
* EACCES). La pause entre deux essais part de PAUSE_INITIALE_MS et double
|
||||
* jusqu'à PAUSE_MAX_MS : 3,75 s d'attente au plus avant de renoncer.
|
||||
* Un appel système qu'un antivirus ou un agent de synchronisation peut
|
||||
* refuser sous Windows, tant qu'il tient le fichier ouvert (EPERM, EBUSY,
|
||||
* EACCES), se réessaie, après son premier essai, REESSAIS_PASSAGERS fois :
|
||||
* le renommage de l'écriture atomique, l'ouverture en ajout d'ajouterLigne,
|
||||
* le renommage de deplacer, l'effacement de supprimer. La pause entre deux
|
||||
* essais part de PAUSE_INITIALE_MS et double jusqu'à PAUSE_MAX_MS : 3,75 s
|
||||
* d'attente au plus avant de renoncer.
|
||||
*/
|
||||
export const REESSAIS_RENOMMAGE = 10;
|
||||
export const REESSAIS_PASSAGERS = 10;
|
||||
export const PAUSE_INITIALE_MS = 50;
|
||||
export const PAUSE_MAX_MS = 500;
|
||||
const CAUSES_PASSAGERES = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
||||
|
|
@ -94,6 +96,17 @@ export const DELAI_SUPPORT_MS = 5000;
|
|||
// une apostrophe le ferait lire autrement.
|
||||
const COMMANDE_LECTEUR = '[System.IO.DriveInfo]::new($env:GTT_RACINE).DriveType';
|
||||
|
||||
// Lit l'heure de création du processus dont la variable GTT_PID porte le
|
||||
// pid, en FILETIME UTC : un entier, le même à chaque lecture. Un processus
|
||||
// absent, ou d'un autre compte — système, service, élevé — que .NET ne peut
|
||||
// ouvrir, ne rend aucun nombre : la vie de la séance reste alors inconnue.
|
||||
const COMMANDE_DEMARRAGE = '(Get-Process -Id $env:GTT_PID -ErrorAction Stop).StartTime.ToFileTimeUtc()';
|
||||
|
||||
// Dans /proc/<pid>/stat, le démarrage est le champ 22 ; les champs se
|
||||
// comptent après la parenthèse fermante du nom, le champ 2, qui peut porter
|
||||
// des espaces et des parenthèses : le champ 3 y a le rang 0.
|
||||
const RANG_DEMARRAGE_STAT = 22 - 3;
|
||||
|
||||
// La règle des segments d'exigerCheminRelatif (src/stockage/systeme_fichiers.js),
|
||||
// recopiée : un segment ne finit ni par un point ni par une espace — « . »,
|
||||
// « .. » et ce que Windows y ramène —, et ne porte ni barre oblique inverse,
|
||||
|
|
@ -240,9 +253,11 @@ function exigerSeance(seance) {
|
|||
if (typeof seance !== 'string' || seance === '') throw new TypeError("la séance n'est pas une chaîne non vide");
|
||||
}
|
||||
|
||||
// Le contenu d'un verrou, {seance, pid, hote, depuis}, ou null quand le texte
|
||||
// n'en est pas un : JSON invalide, champ manquant ou de type faux, pid qui
|
||||
// ne désigne pas un processus — 0 et les négatifs désignent des groupes.
|
||||
// Le contenu d'un verrou, {seance, pid, hote, depuis, demarrage}, ou null
|
||||
// quand le texte n'en est pas un : JSON invalide, champ manquant ou de type
|
||||
// faux, pid qui ne désigne pas un processus — 0 et les négatifs désignent
|
||||
// des groupes. demarrage est une chaîne, null quand il est inconnu ; un
|
||||
// verrou qui ne le porte pas le laisse inconnu. Une autre clé ne compte pas.
|
||||
function analyserVerrou(texte) {
|
||||
let contenu;
|
||||
try {
|
||||
|
|
@ -256,8 +271,9 @@ function analyserVerrou(texte) {
|
|||
Number.isSafeInteger(contenu.pid) &&
|
||||
contenu.pid > 0 &&
|
||||
typeof contenu.hote === 'string' &&
|
||||
typeof contenu.depuis === 'string';
|
||||
return valide ? contenu : null;
|
||||
typeof contenu.depuis === 'string' &&
|
||||
(contenu.demarrage === undefined || contenu.demarrage === null || typeof contenu.demarrage === 'string');
|
||||
return valide ? { ...contenu, demarrage: contenu.demarrage ?? null } : null;
|
||||
}
|
||||
|
||||
// Un instant sous la forme de l'horloge de l'application,
|
||||
|
|
@ -315,6 +331,9 @@ export function processusVivant(pid, signaler = (cible) => process.kill(cible, 0
|
|||
* delai}) → la sortie standard de la commande
|
||||
* @param {(ms: number) => Promise<void>} [reglages.attendre]
|
||||
* @param {(pid: number) => void} [reglages.signaler] celui de processusVivant
|
||||
* @param {(pid: number) => Promise<string|null>} [reglages.demarrage]
|
||||
* l'instant de démarrage d'un processus, null quand il ne se lit pas ;
|
||||
* par défaut, celui que lit la plateforme
|
||||
* @param {number} [reglages.pid]
|
||||
* @param {string} [reglages.hote]
|
||||
* @param {() => Date} [reglages.maintenant]
|
||||
|
|
@ -332,6 +351,7 @@ export function creerFichiers({
|
|||
lancer = lancerCommande,
|
||||
attendre = pause,
|
||||
signaler,
|
||||
demarrage,
|
||||
pid = process.pid,
|
||||
hote = hostname(),
|
||||
maintenant = () => new Date(),
|
||||
|
|
@ -341,6 +361,9 @@ export function creerFichiers({
|
|||
const bases = new Map([['documents', documents]]);
|
||||
if (portable !== null) bases.set('portable', portable);
|
||||
let choisis = 0;
|
||||
const demarrageDe = demarrage ?? lireDemarrage;
|
||||
// Le démarrage du processus courant, lu une fois qu'il est connu.
|
||||
let demarrageCourant = null;
|
||||
|
||||
// Ce que désigne un chemin relatif à une racine : ses segments, sa racine
|
||||
// et sa cible en absolu, et le dossier que nomme un échec — celui qui porte
|
||||
|
|
@ -461,16 +484,17 @@ export function creerFichiers({
|
|||
}
|
||||
}
|
||||
|
||||
// Renomme, et réessaie sur une cause passagère, après une pause qui
|
||||
// double ; la dernière erreur se lève.
|
||||
async function renommer(de, vers) {
|
||||
// Rend ce que rend l'opération, et la réessaie sur une cause passagère,
|
||||
// après une pause qui double ; une autre cause, ou la dernière erreur des
|
||||
// réessais, se lève. L'opération ne doit rien laisser derrière un échec :
|
||||
// elle se rejoue entière.
|
||||
async function reessayer(operation) {
|
||||
let attente = PAUSE_INITIALE_MS;
|
||||
for (let essai = 0; ; essai += 1) {
|
||||
try {
|
||||
await fs.rename(de, vers);
|
||||
return;
|
||||
return await operation();
|
||||
} catch (erreur) {
|
||||
if (essai === REESSAIS_RENOMMAGE || !CAUSES_PASSAGERES.has(erreur?.code)) throw erreur;
|
||||
if (essai === REESSAIS_PASSAGERS || !CAUSES_PASSAGERES.has(erreur?.code)) throw erreur;
|
||||
}
|
||||
await attendre(attente);
|
||||
attente = Math.min(2 * attente, PAUSE_MAX_MS);
|
||||
|
|
@ -488,13 +512,74 @@ export function creerFichiers({
|
|||
const poignee = await fs.open(temporaire, 'wx');
|
||||
try {
|
||||
await ecrireDans(poignee, octets);
|
||||
await renommer(temporaire, absolu);
|
||||
await reessayer(() => fs.rename(temporaire, absolu));
|
||||
} catch (erreur) {
|
||||
await effacer(temporaire).catch(() => {});
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
|
||||
// L'instant de démarrage d'un processus de ce poste, tel que la plateforme
|
||||
// le donne sans module natif : sous Linux le champ 22 de /proc/<pid>/stat,
|
||||
// en tops d'horloge depuis l'amorçage ; sous Windows l'heure de création
|
||||
// que lit PowerShell ; sous macOS la colonne lstart de ps, en locale C et
|
||||
// en temps universel — ps l'écrit sinon dans le fuseau du poste, qui peut
|
||||
// changer entre la prise du verrou et sa relecture. Une
|
||||
// lecture qui échoue, une sortie de forme fausse ou une autre plateforme
|
||||
// rendent null. Un pid réattribué, après un redémarrage, désigne un
|
||||
// processus d'un autre démarrage.
|
||||
async function lireDemarrage(cible) {
|
||||
try {
|
||||
if (plateforme === 'linux') {
|
||||
const texte = (await fs.readFile(`/proc/${cible}/stat`)).toString('utf8');
|
||||
const fin = texte.lastIndexOf(')');
|
||||
if (fin < 0) return null;
|
||||
const valeur = texte.slice(fin + 1).trim().split(/\s+/)[RANG_DEMARRAGE_STAT];
|
||||
return /^\d+$/.test(valeur ?? '') ? valeur : null;
|
||||
}
|
||||
if (plateforme === 'win32') {
|
||||
const sortie = await lancer('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', COMMANDE_DEMARRAGE], {
|
||||
variables: { GTT_PID: String(cible) },
|
||||
delai: DELAI_SUPPORT_MS,
|
||||
});
|
||||
const valeur = String(sortie).trim();
|
||||
return /^\d+$/.test(valeur) ? valeur : null;
|
||||
}
|
||||
if (plateforme === 'darwin') {
|
||||
const sortie = await lancer('ps', ['-o', 'lstart=', '-p', String(cible)], {
|
||||
variables: { LC_ALL: 'C', TZ: 'UTC' },
|
||||
delai: DELAI_SUPPORT_MS,
|
||||
});
|
||||
const valeur = String(sortie).trim();
|
||||
return valeur === '' ? null : valeur;
|
||||
}
|
||||
} catch {
|
||||
// Un processus disparu, une commande qui échoue ou dépasse son délai :
|
||||
// le démarrage reste inconnu.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Le démarrage du processus courant ; un inconnu se relit à l'appel
|
||||
// suivant, un connu non.
|
||||
async function monDemarrage() {
|
||||
if (demarrageCourant === null) demarrageCourant = await demarrageDe(pid);
|
||||
return demarrageCourant;
|
||||
}
|
||||
|
||||
// La vie de la séance d'un verrou de ce poste. Faux seulement quand sa
|
||||
// mort est certaine : aucun processus de ce pid ne répond, ou le
|
||||
// démarrage inscrit et celui du processus actuel sont connus tous deux et
|
||||
// diffèrent — le pid est passé à un autre processus. Vrai quand les deux
|
||||
// démarrages sont connus et égaux. Null sinon : un démarrage inconnu d'un
|
||||
// côté ou de l'autre ne prouve ni la vie ni la mort, et une séance dite
|
||||
// morte à tort perdrait son verrou au profit d'un second écrivain.
|
||||
async function seanceVivante(contenu) {
|
||||
if (!processusVivant(contenu.pid, signaler)) return false;
|
||||
const actuel = contenu.demarrage === null ? null : await demarrageDe(contenu.pid);
|
||||
return actuel === null ? null : actuel === contenu.demarrage;
|
||||
}
|
||||
|
||||
// Ce que rend verrouiller d'un verrou présent, ou null quand il a disparu
|
||||
// depuis. Un verrou qui ne se lit pas rend seance, depuis et vivant à null.
|
||||
async function verrouPresent(absolu) {
|
||||
|
|
@ -511,7 +596,7 @@ export function creerFichiers({
|
|||
pris: false,
|
||||
seance: contenu.seance,
|
||||
depuis: contenu.depuis,
|
||||
vivant: contenu.hote === hote ? processusVivant(contenu.pid, signaler) : null,
|
||||
vivant: contenu.hote === hote ? await seanceVivante(contenu) : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -640,13 +725,15 @@ export function creerFichiers({
|
|||
},
|
||||
|
||||
// Ouvre en ajout — le fichier absent se crée, son dossier jamais —,
|
||||
// écrit la ligne et sa fin de ligne, vide sur le disque et ferme.
|
||||
// écrit la ligne et sa fin de ligne, vide sur le disque et ferme. Seule
|
||||
// l'ouverture se réessaie : une écriture refusée a pu laisser une partie
|
||||
// de la ligne, qu'un second essai prolongerait.
|
||||
async ajouterLigne(racine, chemin, ligne) {
|
||||
exigerLigne(ligne);
|
||||
const loc = localiser(racine, chemin);
|
||||
try {
|
||||
await confiner(loc);
|
||||
await ecrireDans(await fs.open(loc.absolu, 'a'), `${ligne}\n`);
|
||||
await ecrireDans(await reessayer(() => fs.open(loc.absolu, 'a')), `${ligne}\n`);
|
||||
} catch (erreur) {
|
||||
throw echecEcriture(loc, erreur);
|
||||
}
|
||||
|
|
@ -696,7 +783,7 @@ export function creerFichiers({
|
|||
}
|
||||
},
|
||||
|
||||
// Un fichier, en un seul essai. rename écrase une cible existante sous
|
||||
// Un fichier. rename écrase une cible existante sous
|
||||
// POSIX : sa présence se contrôle d'abord, comme le système compare les
|
||||
// noms, si bien que sans égard à la casse une autre casse du même nom
|
||||
// est une cible présente. Un dossier absent sous la cible nomme la
|
||||
|
|
@ -721,7 +808,7 @@ export function creerFichiers({
|
|||
throw echecEcriture(cible, erreur);
|
||||
}
|
||||
try {
|
||||
await fs.rename(source.absolu, cible.absolu);
|
||||
await reessayer(() => fs.rename(source.absolu, cible.absolu));
|
||||
} catch (erreur) {
|
||||
throw echecEcriture(source, erreur);
|
||||
}
|
||||
|
|
@ -733,21 +820,26 @@ export function creerFichiers({
|
|||
await confiner(loc);
|
||||
const etat = await etatDe(loc.absolu);
|
||||
if (etat?.isDirectory()) throw refus(loc, 'EISDIR');
|
||||
if (etat !== null) await effacer(loc.absolu);
|
||||
if (etat !== null) await reessayer(() => effacer(loc.absolu));
|
||||
} catch (erreur) {
|
||||
throw echecEcriture(loc, erreur);
|
||||
}
|
||||
},
|
||||
|
||||
// Création exclusive (wx), qui ne suit aucun lien. Un verrou présent se
|
||||
// lit ; s'il disparaît entre la création refusée et sa lecture, la prise
|
||||
// se tente une seconde fois. Un verrou écrit à moitié s'efface.
|
||||
// Création exclusive (wx), qui ne suit aucun lien, d'un verrou qui
|
||||
// inscrit la séance, le pid, l'hôte, l'instant de la prise et le
|
||||
// démarrage du processus, lus avant la création : la création et
|
||||
// l'écriture se suivent sans attente. Un verrou présent
|
||||
// se lit ; s'il disparaît entre la création refusée et sa lecture, la
|
||||
// prise se tente une seconde fois. Un verrou écrit à moitié s'efface.
|
||||
async verrouiller(racine, chemin, seance) {
|
||||
exigerSeance(seance);
|
||||
const loc = localiser(racine, chemin);
|
||||
try {
|
||||
await confiner(loc);
|
||||
const contenu = `${JSON.stringify({ seance, pid, hote, depuis: horodatage(maintenant()) })}\n`;
|
||||
const demarrageInscrit = await monDemarrage();
|
||||
const inscrit = { seance, pid, hote, depuis: horodatage(maintenant()), demarrage: demarrageInscrit };
|
||||
const contenu = `${JSON.stringify(inscrit)}\n`;
|
||||
for (let essai = 0; ; essai += 1) {
|
||||
let poignee;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ const TEXTES = new Map([
|
|||
);
|
||||
}
|
||||
return (
|
||||
`Un verrou d'un autre poste, ou illisible, tient cet événement${depuisQuand(depuis)} : ` +
|
||||
`Un verrou dont la séance ne se laisse pas vérifier — autre poste, verrou illisible, ou séance de ce poste dont la vie ne se prouve pas — tient cet événement${depuisQuand(depuis)} : ` +
|
||||
'il reste en lecture ici, et rien ne le reprend seul.'
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -72,9 +72,11 @@ import { ErreurStockage } from './erreurs.js';
|
|||
/**
|
||||
* Ce que rend verrouiller. pris : true quand l'appel a créé le verrou. Sinon,
|
||||
* la séance qui le tient, l'instant de sa prise, et vivant : vrai quand son
|
||||
* processus tourne sur la même machine, faux quand il y est mort, null quand
|
||||
* il tourne sur une autre machine. Un verrou qui ne se lit pas rend seance,
|
||||
* depuis et vivant à null.
|
||||
* processus tourne sur cette machine avec l'heure de démarrage inscrite ;
|
||||
* faux quand il est mort à coup sûr — aucun processus de ce pid, ou deux
|
||||
* heures de démarrage connues qui diffèrent ; null sinon — verrou d'une autre
|
||||
* machine, ou vie qui ne se prouve pas. Un verrou qui ne se lit pas rend
|
||||
* seance, depuis et vivant à null.
|
||||
*
|
||||
* @typedef {{pris: true}
|
||||
* | {pris: false, seance: string|null, depuis: string|null, vivant: boolean|null}} Verrou
|
||||
|
|
@ -133,8 +135,8 @@ import { ErreurStockage } from './erreurs.js';
|
|||
* @property {(r: Racine, chemin: string) => Promise<void>} supprimer
|
||||
* un fichier ; un fichier absent n'est pas une faute
|
||||
* @property {(r: Racine, chemin: string, seance: string) => Promise<Verrou>} verrouiller
|
||||
* création exclusive de {seance, pid, hote, depuis} : un verrou
|
||||
* présent n'est jamais repris, fût-il de la même séance
|
||||
* création exclusive de {seance, pid, hote, depuis, demarrage} :
|
||||
* un verrou présent n'est jamais repris, fût-il de la même séance
|
||||
* @property {(r: Racine, chemin: string, seance: string) => Promise<void>} deverrouiller
|
||||
* n'efface que le verrou de cette séance ; un verrou absent n'est
|
||||
* pas une faute
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@
|
|||
// Suivent la sonde d'écriture, qui ne s'éprouve utilement que contre un vrai
|
||||
// système de fichiers, un dossier en lecture seule, un lien qui sort de la
|
||||
// racine, un dossier voisin dont le nom prolonge celui de la racine, le
|
||||
// renommage qui remplace la cible, et le verrou d'un processus vivant ou
|
||||
// terminé. Chaque épreuve écrit sous ses propres dossiers
|
||||
// temporaires, retirés à sa fin, qu'elle passe ou non.
|
||||
// renommage qui remplace la cible, le verrou d'un processus vivant ou
|
||||
// terminé, sous Linux celui d'un pid vivant dont le démarrage n'est pas
|
||||
// celui qu'il inscrit, et le démarrage que ps écrit en temps universel quel
|
||||
// que soit le fuseau du processus qui le lit. Chaque épreuve écrit sous ses
|
||||
// propres dossiers temporaires, retirés à sa fin, qu'elle passe ou non.
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
|
|
@ -42,6 +44,7 @@ const NOM_PRODUIT = 'Gestion table tournante Libre';
|
|||
// un lien symbolique demande un privilège : un lien de dossier s'y pose en
|
||||
// jonction. Sous root, les droits d'un dossier n'arrêtent aucune écriture.
|
||||
const SOUS_WINDOWS = process.platform === 'win32';
|
||||
const SOUS_LINUX = process.platform === 'linux';
|
||||
const SOUS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0;
|
||||
|
||||
// Dossiers temporaires de l'épreuve en cours.
|
||||
|
|
@ -303,4 +306,92 @@ describe('coquille sur un vrai disque', () => {
|
|||
vivant: false,
|
||||
});
|
||||
});
|
||||
|
||||
testNettoye(
|
||||
"sous Linux, le verrou inscrit le champ 22 de /proc/<pid>/stat ; un pid vivant d'un autre démarrage est une séance morte, et un verrou sans démarrage laisse vivant inconnu",
|
||||
{ skip: !SOUS_LINUX },
|
||||
async () => {
|
||||
// Le démarrage d'un processus, lu ici sans passer par la coquille.
|
||||
const demarrageDe = (cible) => {
|
||||
const texte = readFileSync(`/proc/${cible}/stat`, 'utf8');
|
||||
return texte.slice(texte.lastIndexOf(')') + 1).trim().split(/\s+/)[19];
|
||||
};
|
||||
const { fs, documents } = systemeSur(dossierTemporaire());
|
||||
mkdirSync(documents, { recursive: true });
|
||||
const racine = (await fs.racines()).documents;
|
||||
await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a');
|
||||
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
|
||||
assert.deepEqual(Object.keys(pose), ['seance', 'pid', 'hote', 'depuis', 'demarrage']);
|
||||
assert.equal(pose.demarrage, demarrageDe(process.pid));
|
||||
|
||||
// Le processus parent vit ; un verrou à son pid ne vit qu'avec son
|
||||
// démarrage, et sans démarrage inscrit, rien ne dit s'il vit encore.
|
||||
const parent = process.ppid;
|
||||
const depuis = '2026-10-06T19:02:11-04:00';
|
||||
for (const [nom, inscrit, vivant] of [
|
||||
['son démarrage', { demarrage: demarrageDe(parent) }, true],
|
||||
['son démarrage, une clé étrangère', { demarrage: demarrageDe(parent), inconnue: 1 }, true],
|
||||
['un autre démarrage', { demarrage: `${demarrageDe(parent)}0` }, false],
|
||||
['sans démarrage', {}, null],
|
||||
]) {
|
||||
writeFileSync(
|
||||
join(documents, 'parent.gtt.verrou'),
|
||||
`${JSON.stringify({ seance: 'seance-p', pid: parent, hote: hostname(), depuis, ...inscrit })}\n`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await fs.verrouiller(racine, 'parent.gtt.verrou', 'seance-b'),
|
||||
{ pris: false, seance: 'seance-p', depuis, vivant },
|
||||
nom,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testNettoye(
|
||||
"la lecture par ps (macOS) passe TZ=UTC et LC_ALL=C à la commande : le démarrage inscrit est en temps universel, et un processus d'un autre fuseau lit la séance vivante",
|
||||
{ skip: SOUS_WINDOWS },
|
||||
async () => {
|
||||
const documents = join(dossierTemporaire(), 'Documents');
|
||||
mkdirSync(documents, { recursive: true });
|
||||
const fichiers = creerFichiers({
|
||||
emplacements: {},
|
||||
racines: { portable: null, documents },
|
||||
dialog: SANS_APPEL,
|
||||
shell: SANS_APPEL,
|
||||
plateforme: 'darwin',
|
||||
});
|
||||
assert.deepEqual(await fichiers.verrouiller({ id: 'documents' }, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
|
||||
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
|
||||
const enUtc = spawnSync('ps', ['-o', 'lstart=', '-p', String(process.pid)], {
|
||||
env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(enUtc.status, 0, enUtc.stderr);
|
||||
assert.equal(pose.demarrage, enUtc.stdout.trim());
|
||||
|
||||
// Un second processus, dans un fuseau qui n'est pas celui de
|
||||
// l'épreuve, relit le verrou par la même coquille.
|
||||
const autre = Intl.DateTimeFormat().resolvedOptions().timeZone === 'Pacific/Kiritimati' ? 'Pacific/Pago_Pago' : 'Pacific/Kiritimati';
|
||||
const lecteur = [
|
||||
'const { creerFichiers } = await import(process.env.GTT_MODULE);',
|
||||
'const fichiers = creerFichiers({ emplacements: {}, racines: { portable: null, documents: process.env.GTT_DOCUMENTS }, dialog: {}, shell: {}, plateforme: "darwin" });',
|
||||
'const tenu = await fichiers.verrouiller({ id: "documents" }, "soiree.gtt.verrou", "seance-b");',
|
||||
'process.stdout.write(JSON.stringify({ fuseau: Intl.DateTimeFormat().resolvedOptions().timeZone, tenu }));',
|
||||
].join('\n');
|
||||
const relu = spawnSync(process.execPath, ['--input-type=module', '-e', lecteur], {
|
||||
env: {
|
||||
...process.env,
|
||||
TZ: autre,
|
||||
GTT_MODULE: new URL('../electron/fichiers.js', import.meta.url).href,
|
||||
GTT_DOCUMENTS: documents,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(relu.status, 0, relu.stderr);
|
||||
assert.deepEqual(JSON.parse(relu.stdout), {
|
||||
fuseau: autre,
|
||||
tenu: { pris: false, seance: 'seance-a', depuis: pose.depuis, vivant: true },
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
PAUSE_MAX_MS,
|
||||
PREFIXE_CANAL,
|
||||
PRIMITIVES,
|
||||
REESSAIS_RENOMMAGE,
|
||||
REESSAIS_PASSAGERS,
|
||||
RefusFichier,
|
||||
SUFFIXE_ECRITURE,
|
||||
TEMOIN,
|
||||
|
|
@ -338,7 +338,8 @@ function dialogueFactice(reponses = []) {
|
|||
* La coquille sur un disque en mémoire où la racine documents existe. Rend
|
||||
* le disque, les primitives, et ce que l'épreuve lit des injections : les
|
||||
* pauses demandées, les dossiers ouverts dans l'explorateur, les commandes
|
||||
* lancées, les pid signalés.
|
||||
* lancées, les pid signalés. demarrage, quand il est donné, remplace la
|
||||
* lecture de l'instant de démarrage d'un processus.
|
||||
*/
|
||||
function preparer({
|
||||
disque = creerDisque(),
|
||||
|
|
@ -346,6 +347,7 @@ function preparer({
|
|||
ouvrir = async () => '',
|
||||
lancer = async () => '',
|
||||
signaler = () => {},
|
||||
demarrage,
|
||||
plateforme = 'linux',
|
||||
hote = HOTE,
|
||||
fs = disque.fs,
|
||||
|
|
@ -380,6 +382,7 @@ function preparer({
|
|||
signaux.push(pid);
|
||||
return signaler(pid);
|
||||
},
|
||||
demarrage,
|
||||
pid: PID,
|
||||
hote,
|
||||
maintenant: () => INSTANT,
|
||||
|
|
@ -387,6 +390,28 @@ function preparer({
|
|||
return { disque, fichiers, dialog, pauses, ouvertures, commandes, signaux };
|
||||
}
|
||||
|
||||
/**
|
||||
* Une table de processus du poste : pid → instant de démarrage, null quand
|
||||
* la plateforme ne le donne pas. signaler lève ESRCH pour un pid absent de
|
||||
* la table ; demarrage rend l'instant de la table et consigne le pid lu.
|
||||
*
|
||||
* @param {Array<[number, string|null]>} entrees
|
||||
*/
|
||||
function tableDeProcessus(entrees) {
|
||||
const table = new Map(entrees);
|
||||
const lus = [];
|
||||
return {
|
||||
lus,
|
||||
signaler(pid) {
|
||||
if (!table.has(pid)) throw erreurSysteme('ESRCH', 'kill', '');
|
||||
},
|
||||
async demarrage(pid) {
|
||||
lus.push(pid);
|
||||
return table.has(pid) ? table.get(pid) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Rend la raison du rejet de la promesse ; échoue quand elle se résout.
|
||||
async function rejette(promesse, message) {
|
||||
let raison;
|
||||
|
|
@ -468,7 +493,7 @@ describe('coquille, fichiers : écriture atomique (§ 8.8)', () => {
|
|||
});
|
||||
|
||||
test('EPERM, EBUSY et EACCES se réessaient dix fois : dix refus passent, onze lèvent ECRITURE {chemin, dossier, cause}, la cible intacte, le temporaire retiré', async () => {
|
||||
assert.equal(REESSAIS_RENOMMAGE, 10);
|
||||
assert.equal(REESSAIS_PASSAGERS, 10);
|
||||
assert.deepEqual([PAUSE_INITIALE_MS, PAUSE_MAX_MS], [50, 500]);
|
||||
const PAUSES = [50, 100, 200, 400, 500, 500, 500, 500, 500, 500];
|
||||
for (const cause of ['EPERM', 'EBUSY', 'EACCES']) {
|
||||
|
|
@ -793,7 +818,7 @@ describe('coquille, fichiers : lecture, liste, dossiers, déplacement, suppressi
|
|||
assert.deepEqual(disque.noms(`${DATA}/corbeille`), ['2026-01-02_03-04-05']);
|
||||
});
|
||||
|
||||
test("deplacer porte un fichier ; EXISTE pour une cible présente, ABSENT pour une source absente, ECRITURE pour un dossier, un dossier de cible absent ou un renommage refusé, essayé une fois", async () => {
|
||||
test("deplacer porte un fichier ; EXISTE pour une cible présente, ABSENT pour une source absente, ECRITURE pour un dossier, un dossier de cible absent ou un renommage refusé pour une cause durable, essayé une fois", async () => {
|
||||
const { disque, fichiers, pauses } = preparer();
|
||||
disque.deposer(`${DOCUMENTS}/soiree.gtt.json`, '\u{E9}tat');
|
||||
disque.deposerDossier(`${DOCUMENTS}/corbeille`);
|
||||
|
|
@ -811,8 +836,8 @@ describe('coquille, fichiers : lecture, liste, dossiers, déplacement, suppressi
|
|||
'ECRITURE',
|
||||
ecriture('disparu/source.txt', `${DOCUMENTS}/disparu`, 'ENOENT'),
|
||||
);
|
||||
disque.panne('rename', 'EBUSY');
|
||||
await refuse(fichiers.deplacer(DOC, 'source.txt', 'autre.txt'), 'ECRITURE', ecriture('source.txt', DOCUMENTS, 'EBUSY'));
|
||||
disque.panne('rename', 'EROFS');
|
||||
await refuse(fichiers.deplacer(DOC, 'source.txt', 'autre.txt'), 'ECRITURE', ecriture('source.txt', DOCUMENTS, 'EROFS'));
|
||||
assert.equal(disque.appels.filter((ligne) => ligne.startsWith(`rename ${DOCUMENTS}/source.txt`)).length, 1);
|
||||
assert.deepEqual(pauses, []);
|
||||
assert.equal(disque.contenu(`${DOCUMENTS}/source.txt`), 'source');
|
||||
|
|
@ -833,27 +858,153 @@ describe('coquille, fichiers : lecture, liste, dossiers, déplacement, suppressi
|
|||
});
|
||||
});
|
||||
|
||||
describe('coquille, fichiers : réessais sur une cause passagère (§ 8.8)', () => {
|
||||
const JOURNAL = `${DOCUMENTS}/soiree.gtt-journal.jsonl`;
|
||||
const PAUSES = [50, 100, 200, 400, 500, 500, 500, 500, 500, 500];
|
||||
|
||||
// Chaque primitive, l'appel système qu'un antivirus ou un agent de
|
||||
// synchronisation peut refuser sous Windows, le disque préparé, l'appel,
|
||||
// ce qu'il laisse quand il réussit, et quand il échoue.
|
||||
const CAS = [
|
||||
{
|
||||
primitive: 'ajouterLigne',
|
||||
fonction: 'open',
|
||||
chemin: JOURNAL,
|
||||
poser: (disque) => disque.deposer(JOURNAL, '{"type":"journal"}\n'),
|
||||
appeler: (fichiers) => fichiers.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', '{"revision":1}'),
|
||||
reussi: (disque) => assert.equal(disque.contenu(JOURNAL), '{"type":"journal"}\n{"revision":1}\n'),
|
||||
intact: (disque) => assert.equal(disque.contenu(JOURNAL), '{"type":"journal"}\n'),
|
||||
refus: ecriture('soiree.gtt-journal.jsonl', DOCUMENTS, null),
|
||||
},
|
||||
{
|
||||
primitive: 'deplacer',
|
||||
fonction: 'rename',
|
||||
chemin: `${DOCUMENTS}/soiree.gtt.json`,
|
||||
poser: (disque) => {
|
||||
disque.deposer(`${DOCUMENTS}/soiree.gtt.json`, '\u{E9}tat');
|
||||
disque.deposerDossier(`${DOCUMENTS}/corbeille`);
|
||||
},
|
||||
appeler: (fichiers) => fichiers.deplacer(DOC, 'soiree.gtt.json', 'corbeille/soiree.gtt.json'),
|
||||
reussi: (disque) => {
|
||||
assert.equal(disque.contenu(`${DOCUMENTS}/corbeille/soiree.gtt.json`), '\u{E9}tat');
|
||||
assert.equal(disque.existe(`${DOCUMENTS}/soiree.gtt.json`), false);
|
||||
},
|
||||
intact: (disque) => {
|
||||
assert.equal(disque.contenu(`${DOCUMENTS}/soiree.gtt.json`), '\u{E9}tat');
|
||||
assert.equal(disque.existe(`${DOCUMENTS}/corbeille/soiree.gtt.json`), false);
|
||||
},
|
||||
refus: ecriture('soiree.gtt.json', DOCUMENTS, null),
|
||||
},
|
||||
{
|
||||
primitive: 'supprimer',
|
||||
fonction: 'unlink',
|
||||
chemin: `${DOCUMENTS}/ancienne.gtt.json`,
|
||||
poser: (disque) => disque.deposer(`${DOCUMENTS}/ancienne.gtt.json`, 'x'),
|
||||
appeler: (fichiers) => fichiers.supprimer(DOC, 'ancienne.gtt.json'),
|
||||
reussi: (disque) => assert.equal(disque.existe(`${DOCUMENTS}/ancienne.gtt.json`), false),
|
||||
intact: (disque) => assert.equal(disque.contenu(`${DOCUMENTS}/ancienne.gtt.json`), 'x'),
|
||||
refus: ecriture('ancienne.gtt.json', DOCUMENTS, null),
|
||||
},
|
||||
];
|
||||
const essais = (disque, { fonction, chemin }) =>
|
||||
disque.appels.filter((ligne) => ligne.startsWith(`${fonction} ${chemin}`)).length;
|
||||
|
||||
for (const cas of CAS) {
|
||||
test(`${cas.primitive} : refusé deux fois sur une cause passagère puis accepté, réussit après des pauses de 50 puis 100 ms`, async () => {
|
||||
for (const cause of ['EPERM', 'EBUSY', 'EACCES']) {
|
||||
const { disque, fichiers, pauses } = preparer();
|
||||
cas.poser(disque);
|
||||
disque.panne(cas.fonction, cause, { fois: 2, chemin: cas.chemin });
|
||||
await cas.appeler(fichiers);
|
||||
cas.reussi(disque);
|
||||
assert.deepEqual(pauses, [50, 100], cause);
|
||||
assert.equal(essais(disque, cas), 3, cause);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${cas.primitive} : toujours refusé sur une cause passagère, lève ECRITURE au onzième essai, après les pauses du renommage, et ne touche à rien`, async () => {
|
||||
for (const cause of ['EPERM', 'EBUSY', 'EACCES']) {
|
||||
const { disque, fichiers, pauses } = preparer();
|
||||
cas.poser(disque);
|
||||
disque.panne(cas.fonction, cause, { chemin: cas.chemin });
|
||||
await refuse(cas.appeler(fichiers), 'ECRITURE', { ...cas.refus, cause }, cause);
|
||||
cas.intact(disque);
|
||||
assert.deepEqual(pauses, PAUSES, cause);
|
||||
assert.equal(essais(disque, cas), REESSAIS_PASSAGERS + 1, cause);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${cas.primitive} : une cause durable ne se réessaie pas`, async () => {
|
||||
for (const cause of ['EROFS', 'ENOSPC', 'EIO']) {
|
||||
const { disque, fichiers, pauses } = preparer();
|
||||
cas.poser(disque);
|
||||
disque.panne(cas.fonction, cause, { chemin: cas.chemin });
|
||||
await refuse(cas.appeler(fichiers), 'ECRITURE', { ...cas.refus, cause }, cause);
|
||||
cas.intact(disque);
|
||||
assert.deepEqual(pauses, [], cause);
|
||||
assert.equal(essais(disque, cas), 1, cause);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("ajouterLigne ne réessaie que l'ouverture : une écriture refusée ne se répète pas, une ligne n'est jamais écrite deux fois", async () => {
|
||||
const { disque, fichiers, pauses } = preparer();
|
||||
disque.deposer(JOURNAL, '{"type":"journal"}\n');
|
||||
disque.panne('write', 'EBUSY', { chemin: JOURNAL });
|
||||
await refuse(fichiers.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', 'x'), 'ECRITURE', ecriture('soiree.gtt-journal.jsonl', DOCUMENTS, 'EBUSY'));
|
||||
assert.deepEqual(pauses, []);
|
||||
assert.equal(disque.appels.filter((ligne) => ligne.startsWith(`write ${JOURNAL}`)).length, 1);
|
||||
});
|
||||
|
||||
test("supprimer : un fichier qui disparaît pendant les réessais est supprimé, sans faute", async () => {
|
||||
const CIBLE = `${DOCUMENTS}/ancienne.gtt.json`;
|
||||
const disque = creerDisque();
|
||||
// Le premier effacement retire le fichier mais répond EBUSY, comme un
|
||||
// effacement que le système ne fait qu'au dernier fichier fermé : le
|
||||
// réessai trouve ENOENT.
|
||||
let premier = true;
|
||||
const fs = {
|
||||
...disque.fs,
|
||||
async unlink(chemin) {
|
||||
await disque.fs.unlink(chemin);
|
||||
if (premier && chemin === CIBLE) {
|
||||
premier = false;
|
||||
throw erreurSysteme('EBUSY', 'unlink', chemin);
|
||||
}
|
||||
},
|
||||
};
|
||||
const { fichiers, pauses } = preparer({ disque, fs });
|
||||
disque.deposer(CIBLE, 'x');
|
||||
await fichiers.supprimer(DOC, 'ancienne.gtt.json');
|
||||
assert.equal(disque.existe(CIBLE), false);
|
||||
assert.deepEqual(pauses, [50]);
|
||||
assert.equal(disque.appels.filter((ligne) => ligne.startsWith(`unlink ${CIBLE}`)).length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
||||
const VERROU = `${DOCUMENTS}/soiree.gtt.verrou`;
|
||||
const deposerVerrou = (disque, contenu) => disque.deposer(VERROU, `${JSON.stringify(contenu)}\n`);
|
||||
|
||||
test("se crée en exclusif, porte {seance, pid, hote, depuis} et une fin de ligne ; depuis est l'instant de l'horloge, à l'heure locale et avec son décalage", async () => {
|
||||
const { disque, fichiers } = preparer();
|
||||
test("se crée en exclusif, porte {seance, pid, hote, depuis, demarrage} et une fin de ligne ; depuis est l'instant de l'horloge, à l'heure locale et avec son décalage", async () => {
|
||||
const processus = tableDeProcessus([[PID, '918273']]);
|
||||
const { disque, fichiers } = preparer(processus);
|
||||
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
|
||||
assert.deepEqual(ecritures(disque).slice(0, 4), [`open ${VERROU} wx`, `write ${VERROU}`, `sync ${VERROU}`, `close ${VERROU}`]);
|
||||
const texte = disque.contenu(VERROU);
|
||||
assert.ok(texte.endsWith('}\n'), texte);
|
||||
const contenu = JSON.parse(texte);
|
||||
assert.deepEqual(Object.keys(contenu), ['seance', 'pid', 'hote', 'depuis']);
|
||||
assert.deepEqual({ ...contenu, depuis: null }, { seance: 'seance-a', pid: PID, hote: HOTE, depuis: null });
|
||||
assert.deepEqual(Object.keys(contenu), ['seance', 'pid', 'hote', 'depuis', 'demarrage']);
|
||||
assert.deepEqual({ ...contenu, depuis: null }, { seance: 'seance-a', pid: PID, hote: HOTE, depuis: null, demarrage: '918273' });
|
||||
assert.deepEqual(processus.lus, [PID]);
|
||||
assert.match(contenu.depuis, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/);
|
||||
assert.equal(Date.parse(contenu.depuis), INSTANT.getTime());
|
||||
});
|
||||
|
||||
test('un verrou présent rend sa séance : vivant quand son processus répond sur cette machine, même sans droit de le signaler', async () => {
|
||||
test("un verrou présent rend sa séance : morte quand son pid ne répond pas ; sans démarrage inscrit, un pid qui répond, même sans droit de le signaler, laisse vivant inconnu", async () => {
|
||||
for (const [nom, signaler, vivant] of [
|
||||
['répond', () => {}, true],
|
||||
['EPERM', () => { throw erreurSysteme('EPERM', 'kill', ''); }, true],
|
||||
['répond', () => {}, null],
|
||||
['EPERM', () => { throw erreurSysteme('EPERM', 'kill', ''); }, null],
|
||||
['ESRCH', () => { throw erreurSysteme('ESRCH', 'kill', ''); }, false],
|
||||
]) {
|
||||
const { disque, fichiers, signaux } = preparer({ signaler });
|
||||
|
|
@ -867,6 +1018,181 @@ describe('coquille, fichiers : le verrou (§ 8.8)', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("sans instant de démarrage que la plateforme donne, le verrou porte demarrage à null ; un démarrage courant inconnu se relit à la prise suivante, un connu ne se relit pas", async () => {
|
||||
// Le démarrage du processus courant ne se lit pas la première fois.
|
||||
const reponses = [null, '918273'];
|
||||
const lus = [];
|
||||
const { disque, fichiers } = preparer({
|
||||
demarrage: async (cible) => {
|
||||
lus.push(cible);
|
||||
return reponses.length > 1 ? reponses.shift() : reponses[0];
|
||||
},
|
||||
});
|
||||
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
||||
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, null);
|
||||
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
||||
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
||||
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, '918273');
|
||||
await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
||||
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
||||
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, '918273');
|
||||
assert.deepEqual(lus, [PID, PID]);
|
||||
});
|
||||
|
||||
test("vivant : faux seulement quand la mort est certaine — pid qui ne répond pas, ou deux démarrages connus et différents ; vrai pour deux démarrages connus et égaux ; inconnu (null) sinon", async () => {
|
||||
const DEPUIS = '2026-10-06T19:02:11-04:00';
|
||||
const tenu = (vivant) => ({ pris: false, seance: 'seance-a', depuis: DEPUIS, vivant });
|
||||
// Un démarrage inscrit inconnu ne demande pas celui du pid. Une clé
|
||||
// Une clé que le contenu d'un verrou ne définit pas ne compte pas.
|
||||
for (const [nom, demarrageLu, inscrit, vivant, lus] of [
|
||||
['même démarrage', '5000', { demarrage: '5000' }, true, [PID, 777]],
|
||||
['pid réattribué', '5000', { demarrage: '4000' }, false, [PID, 777]],
|
||||
['démarrage inscrit inconnu', '5000', { demarrage: null }, null, [PID]],
|
||||
['verrou sans démarrage', '5000', {}, null, [PID]],
|
||||
['démarrage actuel inconnu', null, { demarrage: '4000' }, null, [PID, 777]],
|
||||
['même démarrage, clé étrangère', '5000', { demarrage: '5000', inconnue: 1 }, true, [PID, 777]],
|
||||
['démarrage actuel inconnu, clé étrangère', null, { demarrage: '4000', inconnue: 1 }, null, [PID, 777]],
|
||||
]) {
|
||||
const processus = tableDeProcessus([[PID, '1'], [777, demarrageLu]]);
|
||||
const { disque, fichiers } = preparer(processus);
|
||||
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: DEPUIS, ...inscrit });
|
||||
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), tenu(vivant), nom);
|
||||
// Le démarrage du processus courant se lit avant la prise, celui du
|
||||
// pid inscrit une fois le verrou trouvé.
|
||||
assert.deepEqual(processus.lus, lus, nom);
|
||||
}
|
||||
// Un pid qui ne répond pas est mort, sans lecture de son démarrage ; celui
|
||||
// du processus courant, lu une fois, ne se relit pas.
|
||||
const processus = tableDeProcessus([[PID, '1']]);
|
||||
const { disque, fichiers } = preparer(processus);
|
||||
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: DEPUIS, demarrage: '5000' });
|
||||
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), tenu(false));
|
||||
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), tenu(false));
|
||||
assert.deepEqual(processus.lus, [PID]);
|
||||
});
|
||||
|
||||
test("un démarrage inscrit qui n'est ni une chaîne ni null rend le verrou illisible", async () => {
|
||||
const { disque, fichiers } = preparer(tableDeProcessus([[777, '5000']]));
|
||||
for (const inscrit of [{ demarrage: 5000 }, { demarrage: true }, { demarrage: {} }]) {
|
||||
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: 'x', ...inscrit });
|
||||
assert.deepEqual(
|
||||
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'),
|
||||
{ pris: false, seance: null, depuis: null, vivant: null },
|
||||
JSON.stringify(inscrit),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("sous Linux, le démarrage est le champ 22 de /proc/<pid>/stat, compté après la parenthèse fermante du nom ; un fichier absent ou de forme fausse le rend inconnu, et vivant avec lui", async () => {
|
||||
// Champs 3 à 22 : l'état, puis dix-huit nombres, le dernier étant le
|
||||
// démarrage ; le nom porte une espace et une parenthèse fermante.
|
||||
const stat = (pid, debut) => `${pid} (nom ) bizarre) S ${Array.from({ length: 18 }, (_, i) => i + 1).join(' ')} ${debut} 99 0\n`;
|
||||
const disque = creerDisque();
|
||||
disque.deposer(`/proc/${PID}/stat`, stat(PID, 31337));
|
||||
disque.deposer('/proc/777/stat', stat(777, 5000));
|
||||
disque.deposer('/proc/778/stat', stat(778, 4000));
|
||||
disque.deposer('/proc/779/stat', '779 (court) S 1 2 3\n');
|
||||
disque.deposer('/proc/780/stat', stat(780, 'x'));
|
||||
// Sans parenthèse fermante, aucun champ ne se compte, même quand la
|
||||
// ligne en porte assez.
|
||||
disque.deposer('/proc/782/stat', stat(782, 5000).replace(/[()]/g, ''));
|
||||
const { fichiers } = preparer({ disque, signaler: () => {} });
|
||||
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
||||
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, '31337');
|
||||
for (const [pid, vivant] of [[777, true], [778, false], [779, null], [780, null], [781, null], [782, null]]) {
|
||||
deposerVerrou(disque, { seance: 'seance-x', pid, hote: HOTE, depuis: 'x', demarrage: '5000' });
|
||||
assert.equal((await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b')).vivant, vivant, String(pid));
|
||||
}
|
||||
});
|
||||
|
||||
test("sous Windows, le démarrage est l'heure de création que rend PowerShell, le pid passé par l'environnement, borné à cinq secondes ; un échec le rend inconnu, et vivant avec lui", async () => {
|
||||
const reponses = new Map([['777', '133725000000000000\r\n'], ['778', '133700000000000000\r\n'], ['779', '\r\n']]);
|
||||
const { disque, fichiers, commandes } = preparer({
|
||||
plateforme: 'win32',
|
||||
lancer: async (_commande, _arguments, { variables }) => {
|
||||
if (variables.GTT_PID === String(PID)) return '133725999999999999\r\n';
|
||||
if (!reponses.has(variables.GTT_PID)) throw new Error('Get-Process : aucun processus');
|
||||
return reponses.get(variables.GTT_PID);
|
||||
},
|
||||
});
|
||||
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
||||
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, '133725999999999999');
|
||||
assert.deepEqual(commandes.map(({ commande }) => commande), ['powershell.exe']);
|
||||
const [{ arguments_, options }] = commandes;
|
||||
assert.deepEqual(arguments_.slice(0, 3), ['-NoProfile', '-NonInteractive', '-Command']);
|
||||
assert.match(arguments_[3], /\$env:GTT_PID/);
|
||||
assert.match(arguments_[3], /\.StartTime\.ToFileTimeUtc\(\)$/);
|
||||
assert.deepEqual(options, { variables: { GTT_PID: String(PID) }, delai: DELAI_SUPPORT_MS });
|
||||
for (const [pid, vivant] of [[777, true], [778, false], [779, null], [780, null]]) {
|
||||
deposerVerrou(disque, { seance: 'seance-x', pid, hote: HOTE, depuis: 'x', demarrage: '133725000000000000' });
|
||||
assert.equal((await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b')).vivant, vivant, String(pid));
|
||||
}
|
||||
});
|
||||
|
||||
test("sous Windows, un pid qui refuse le signal et dont l'heure de création ne se lit pas — le processus d'un autre compte — laisse vivant inconnu, jamais faux", async () => {
|
||||
const DEPUIS = '2026-10-06T19:02:11-04:00';
|
||||
const { disque, fichiers, signaux } = preparer({
|
||||
plateforme: 'win32',
|
||||
signaler: () => {
|
||||
throw erreurSysteme('EPERM', 'kill', '');
|
||||
},
|
||||
lancer: async (_commande, _arguments, { variables }) => {
|
||||
if (variables.GTT_PID === String(PID)) return '133725999999999999\r\n';
|
||||
throw new Error('Get-Process : accès refusé');
|
||||
},
|
||||
});
|
||||
deposerVerrou(disque, { seance: 'seance-a', pid: 4312, hote: HOTE, depuis: DEPUIS, demarrage: '133725000000000000' });
|
||||
assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), {
|
||||
pris: false,
|
||||
seance: 'seance-a',
|
||||
depuis: DEPUIS,
|
||||
vivant: null,
|
||||
});
|
||||
assert.deepEqual(signaux, [4312]);
|
||||
});
|
||||
|
||||
test("sous macOS, le démarrage est la colonne lstart de ps, en locale C et en temps universel ; une sortie vide le rend inconnu, et vivant avec lui ; ailleurs, inconnu, sans commande", async () => {
|
||||
const sorties = new Map([['777', 'Tue Oct 6 19:02:11 2026\n'], ['779', '\n']]);
|
||||
const { disque, fichiers, commandes } = preparer({
|
||||
plateforme: 'darwin',
|
||||
lancer: async (_commande, arguments_) => sorties.get(arguments_[3]) ?? 'Wed Oct 7 08:00:00 2026\n',
|
||||
});
|
||||
await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
||||
assert.equal(JSON.parse(disque.contenu(VERROU)).demarrage, 'Wed Oct 7 08:00:00 2026');
|
||||
assert.deepEqual(commandes[0], {
|
||||
commande: 'ps',
|
||||
arguments_: ['-o', 'lstart=', '-p', String(PID)],
|
||||
options: { variables: { LC_ALL: 'C', TZ: 'UTC' }, delai: DELAI_SUPPORT_MS },
|
||||
});
|
||||
for (const [pid, vivant] of [[777, true], [778, false], [779, null]]) {
|
||||
deposerVerrou(disque, { seance: 'seance-x', pid, hote: HOTE, depuis: 'x', demarrage: 'Tue Oct 6 19:02:11 2026' });
|
||||
assert.equal((await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b')).vivant, vivant, String(pid));
|
||||
}
|
||||
|
||||
const ailleurs = preparer({ plateforme: 'freebsd' });
|
||||
await ailleurs.fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
|
||||
assert.equal(JSON.parse(ailleurs.disque.contenu(VERROU)).demarrage, null);
|
||||
assert.deepEqual(ailleurs.commandes, []);
|
||||
});
|
||||
|
||||
test("sous macOS, une séance vivante reste vivante quand le fuseau du poste change entre la prise et la lecture", async () => {
|
||||
// Un ps factice : le même démarrage, écrit dans le fuseau que donne TZ,
|
||||
// ou à défaut dans celui du poste au moment de la commande.
|
||||
const ecrit = new Map([
|
||||
['UTC', 'Tue Oct 6 23:02:11 2026'],
|
||||
['America/Montreal', 'Tue Oct 6 19:02:11 2026'],
|
||||
['Europe/Paris', 'Wed Oct 7 01:02:11 2026'],
|
||||
]);
|
||||
let fuseauDuPoste = 'America/Montreal';
|
||||
const lancer = async (_commande, _arguments, { variables }) => `${ecrit.get(variables.TZ ?? fuseauDuPoste)}\n`;
|
||||
const premiere = preparer({ plateforme: 'darwin', lancer });
|
||||
assert.deepEqual(await premiere.fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
|
||||
fuseauDuPoste = 'Europe/Paris';
|
||||
const seconde = preparer({ disque: premiere.disque, plateforme: 'darwin', lancer });
|
||||
const tenu = await seconde.fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b');
|
||||
assert.deepEqual({ seance: tenu.seance, vivant: tenu.vivant }, { seance: 'seance-a', vivant: true });
|
||||
});
|
||||
|
||||
test("le verrou d'une autre machine rend vivant à null sans signaler personne ; un verrou illisible rend tout à null", async () => {
|
||||
const { disque, fichiers, signaux } = preparer();
|
||||
deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: 'poste-voisin', depuis: '2026-10-06T19:02:11-04:00' });
|
||||
|
|
|
|||
Loading…
Reference in a new issue