Skip to content
93 linesCodeBlameRaw
1// `npm ci` that survives a dropped connection: run as
2//
3// node scripts/deploy/npm-ci.mjs [npm ci's flags…]
4//
5// npm retries a request that fails before its answer starts, but not one
6// whose body breaks off midway (`ECONNRESET`, `network aborted`), and an
7// install can also stall with nothing more arriving. Either fails a whole
8// job that passes when run again. So an install whose output names a
9// network failure, or that ends without saying why, is run again (up to
10// `ATTEMPTS` times, after `WAITS_MS`), and one that goes `STALL_MS`
11// without a line of output is stopped and run again. npm's cache keeps
12// what the failed attempt already fetched, so a second try is quick.
13// Anything else, such as a lockfile out of step, fails at once.
14
15import { spawn } from "node:child_process";
16import { fileURLToPath } from "node:url";
17
18export const ATTEMPTS = 3;
19export const WAITS_MS = [5_000, 15_000];
20/** How long an install may print nothing before it counts as stalled. */
21export const STALL_MS = Number(process.env.G1T_NPM_STALL_MS) || 12 * 60_000;
22
23/** npm's words for a connection that failed: worth trying again. */
24const NETWORK = /\b(ECONNRESET|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|EAI_AGAIN|EPIPE|ERR_SOCKET_TIMEOUT)\b|network aborted|socket hang up|network timeout|request to \S+ failed/i;
25
26/**
27 * Whether a failed install is worth running again: its output names a
28 * network failure, it stalled, or it failed without an `npm error` line
29 * that says why (a dropped connection can end npm before it writes one).
30 */
31export function retryable(output, { stalled = false } = {}) {
32 if (stalled) return true;
33 if (NETWORK.test(output)) return true;
34 return !/^npm (error|ERR!)/m.test(output);
35}
36
37/** The npm arguments for these flags: `ci` and the flags as given. */
38export function npmArgs(flags) {
39 return ["ci", ...flags];
40}
41
42/** One install: its exit code, what it printed, and whether it stalled. */
43function attempt(args, { stallMs, onLine }) {
44 return new Promise((resolve) => {
45 const child = spawn("npm", args, { shell: process.platform === "win32", stdio: ["ignore", "pipe", "pipe"] });
46 let output = "";
47 let stalled = false;
48 let timer;
49 const quiet = () => {
50 clearTimeout(timer);
51 timer = setTimeout(() => {
52 stalled = true;
53 child.kill("SIGKILL");
54 }, stallMs);
55 };
56 quiet();
57 const take = (stream, out) =>
58 stream.on("data", (chunk) => {
59 const text = chunk.toString();
60 output += text;
61 out.write(text);
62 onLine?.(text);
63 quiet();
64 });
65 take(child.stdout, process.stdout);
66 take(child.stderr, process.stderr);
67 child.on("close", (code) => {
68 clearTimeout(timer);
69 resolve({ code: stalled ? 1 : (code ?? 1), output, stalled });
70 });
71 });
72}
73
74/** Runs `npm ci` with `flags`, again when it fails in a way worth retrying. Resolves to its exit code. */
75export async function npmCi(flags, { attempts = ATTEMPTS, waits = WAITS_MS, stallMs = STALL_MS, run = attempt, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), log = console.error } = {}) {
76 const args = npmArgs(flags);
77 for (let tries = 1; ; tries++) {
78 const done = await run(args, { stallMs });
79 if (done.code === 0) return 0;
80 const why = done.stalled ? `printed nothing for ${Math.round(stallMs / 1000)} s` : `exited ${done.code}`;
81 if (tries >= attempts || !retryable(done.output, done)) {
82 log(`npm ci ${why}${tries > 1 ? ` on try ${tries} of ${attempts}` : ""}; not trying again.`);
83 return done.code || 1;
84 }
85 const wait = waits[Math.min(tries - 1, waits.length - 1)];
86 log(`npm ci ${why}, which looks like a dropped connection: trying again in ${wait / 1000} s (try ${tries + 1} of ${attempts}).`);
87 await sleep(wait);
88 }
89}
90
91if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
92 process.exitCode = await npmCi(process.argv.slice(2));
93}