Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Every install in g1t's workflows and deploy tool goes through scripts/deploy/npm-ci.mjs, which runs npm ci again when a connection drops: npm does not retry a body that breaks off midway, so an ECONNRESET or network aborted, an install that ends without saying why, or one that prints nothing for 12 minutes is tried again, up to three times; a real error, such as a lockfile out of step, still fails at once. A test keeps every workflow on it. | 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 | ||
| 15 | import { spawn } from "node:child_process"; | |
| 16 | import { fileURLToPath } from "node:url"; | |
| 17 | ||
| 18 | export const ATTEMPTS = 3; | |
| 19 | export const WAITS_MS = [5_000, 15_000]; | |
| 20 | /** How long an install may print nothing before it counts as stalled. */ | |
| 21 | export 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. */ | |
| 24 | const 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 | */ | |
| 31 | export 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. */ | |
| 38 | export function npmArgs(flags) { | |
| 39 | return ["ci", ...flags]; | |
| 40 | } | |
| 41 | ||
| 42 | /** One install: its exit code, what it printed, and whether it stalled. */ | |
| 43 | function 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. */ | |
| 75 | export 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 | ||
| 91 | if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { | |
| 92 | process.exitCode = await npmCi(process.argv.slice(2)); | |
| 93 | } |