Files
ng-eventually/packages/polyfill/e2e/run-lock.ts
T
Sylvain Duchesne c5b4703687 test(e2e): le parcours d'un primo-arrivant, et un harnais qui échoue au lieu de se pendre
Aucun parcours n'avait jamais marché le chemin d'un nouvel arrivant : tous
pré-injectaient l'identifiant dans l'URL, ce qui fait résoudre l'identité sans
jamais afficher la barrière. La suite était verte par-dessus un lien de
téléchargement pointant sur un fichier que personne ne servait — et le serveur
de test répondait la page HTML de l'application pour tout chemin inconnu, donc
un fichier manquant ne POUVAIT pas échouer.

Le nouveau parcours part d'un profil vide : barrière, téléchargement réel,
import dans l'application portefeuille, saisie de l'identifiant, remise au
broker, retour dans l'iframe. Il vérifie neuf points, dont celui qui compte —
l'identifiant a survécu et l'identité rapportée est celle qui a été saisie.

Le harnais, lui, se pendait au lieu d'échouer. Cause observée : le tuyau
devtools de Chromium lâche et Playwright n'émet ni close ni disconnected, si
bien que la suite bloquait dans son propre nettoyage sans imprimer ni résumé ni
l'échec déjà en route. Toutes les attentes sont désormais bornées et nomment ce
qu'elles attendaient ; vérifié en cassant délibérément une attente, et observé
en conditions réelles — trois minutes et « gave up waiting for: alice to sign
in » là où j'ai tué trois exécutions d'une heure ce matin.

Deux exécutions simultanées ne se détruisent plus : verrou atomique sur le
profil, et récupération d'un navigateur laissé par une exécution tuée. Le
marqueur devient .user-consumed — il n'a jamais attesté d'une disponibilité,
seulement qu'un lot avait déjà pris l'utilisateur de ce profil. Au passage, la
destruction du profil dépendait du marqueur, écrit en FIN de lot : une
exécution tuée avant laissait un profil que la suivante réutilisait, et
héritait de sa casse. Elle dépend maintenant du profil.

La suite applicative reste non mesurée sur cette machine : un conteneur en
boucle de redémarrage recycle son interface réseau, et sept exécutions sur dix
échouent sur le transport. Trois sont passées 21/21.
2026-08-11 19:34:09 +02:00

132 lines
5.9 KiB
TypeScript

/**
* One run at a time over the shared wallet profile.
*
* ── Why ──────────────────────────────────────────────────────────────────────
* Both suites call `ensureWallet()`, and its first act is `fs.rmSync(PROFILE_DIR)` — it
* discards the previous batch's physical user on purpose (see `broker.ts`). Started while
* another run is alive, that deletes the profile out from under a browser which is USING
* it, and the first run then fails somewhere far from the cause, looking like a product
* defect. That has already cost several undecidable measurements: a suite blamed for a
* hang that was really a second run wiping its wallet.
*
* So the exclusion is made structural rather than remembered.
*
* ── Fail, not wait ───────────────────────────────────────────────────────────
* A second run is REFUSED, immediately, naming the holder. Queueing would be the wrong
* answer for a harness: these batches run for minutes, and a command that silently blocks
* for a quarter of an hour is the same disease as the hang this was written alongside —
* you cannot tell it from a freeze. A refusal is legible in one line and costs nothing.
*
* ── Where the file lives ─────────────────────────────────────────────────────
* Under the system temp dir, NOT inside the profile it guards: `ensureWallet` deletes that
* directory wholesale, which would erase the lock at the exact moment it is protecting
* something. Naming it after the profile's path keeps one lock per guarded profile, and
* keeps it out of the repository (nothing to gitignore, nothing to commit by accident).
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
interface LockRecord {
pid: number;
suite: string;
startedAt: string;
}
function lockPathFor(guarded: string): string {
const slug = guarded.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
return path.join(os.tmpdir(), `ng-eventually-e2e-${slug}.lock`);
}
/** Is that process still alive? Signal 0 tests for existence without touching it. */
export function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (e) {
// EPERM means it exists and is someone else's — still alive, still holding the lock.
return (e as NodeJS.ErrnoException).code === "EPERM";
}
}
function readRecord(lockPath: string): LockRecord | null {
try {
const parsed: unknown = JSON.parse(fs.readFileSync(lockPath, "utf-8"));
if (parsed && typeof parsed === "object" && typeof (parsed as LockRecord).pid === "number") {
return parsed as LockRecord;
}
return null;
} catch {
return null;
}
}
/**
* Take the lock for `suite` over `guarded`, or throw naming who holds it.
*
* A lock left by a process that no longer exists is taken over — a run killed mid-batch
* (which is how every one of this harness's hangs ended) must not make the next one
* unrunnable. That check is on the OS's view of the pid, not on the file's age: a timeout
* would either strand a slow-but-healthy batch or hand the profile to a second run while
* the first still holds it, and both are the corruption this exists to stop.
*/
export function acquireRunLock(suite: string, guarded: string): void {
const lockPath = lockPathFor(guarded);
const record: LockRecord = { pid: process.pid, suite, startedAt: new Date().toISOString() };
for (let attempt = 0; attempt < 2; attempt++) {
try {
fs.writeFileSync(lockPath, JSON.stringify(record), { flag: "wx" });
installRelease(lockPath);
return;
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
const held = readRecord(lockPath);
if (held !== null && isAlive(held.pid)) {
const ageMin = Math.round((Date.now() - Date.parse(held.startedAt)) / 60000);
throw new Error(
`[e2e] refusing to start: another e2e run holds ${guarded}.\n` +
` holder: ${held.suite} (pid ${held.pid}, started ${held.startedAt}, ${ageMin} min ago)\n` +
" Two runs share one wallet profile, and each one's setup DELETES it — so the\n" +
" second would corrupt the first. Wait for it, or stop it, then run again.\n" +
` If that process is gone, remove ${lockPath}.`,
);
}
// Nobody is behind it: a killed run's leftover. Take it over and say so.
console.warn(
`[e2e] taking over a stale run lock (${held === null ? "unreadable" : `pid ${held.pid} is gone`}) — ${lockPath}`,
);
fs.rmSync(lockPath, { force: true });
}
}
throw new Error(`[e2e] could not take the run lock at ${lockPath} (raced twice)`);
}
/**
* Release on the way out, including the ways out nobody plans for.
*
* `exit` covers the normal end and `process.exit()`, which is how both suites finish; the
* signal handlers cover Ctrl-C and `kill`, which is how a hung batch ends. A lock that
* outlives its run is only a nuisance — the takeover above clears it — but leaving one
* behind on every interrupt would make the nuisance the norm.
*/
function installRelease(lockPath: string): void {
const release = (): void => {
const held = readRecord(lockPath);
if (held !== null && held.pid !== process.pid) return; // someone else's now; leave it
try {
fs.rmSync(lockPath, { force: true });
} catch {
/* the takeover path handles whatever is left */
}
};
process.on("exit", release);
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
process.on(signal, () => {
release();
process.exit(130);
});
}
}