feat(npm): install.js network resilience for slow / firewalled networks

A community user from China reported `npm install deepseek-tui`
took 18 minutes through a CN npm mirror. The bottleneck is the
GitHub Releases binary fetch (~46 MB across two binaries), not the
npm tarball (which is 6.9 kB). The CN mirror does NOT proxy GitHub
release downloads, so any user behind a slow or lossy connection
is hitting the GitHub fetch directly with no resilience.

Four behaviors added to `npm/deepseek-tui/scripts/install.js`:

1. **Retry with exponential backoff.** Up to 5 attempts on network
   errors (ECONNRESET, ECONNREFUSED, ETIMEDOUT, EAI_AGAIN,
   network/host unreachable, EPIPE, ECONNABORTED) and 5xx upstream
   responses. Backoff `1s, 2s, 4s, 8s, 16s` with ±20% jitter. 4xx
   and checksum-mismatch are flagged non-retryable so we don't
   thrash on permanent failures. Final error includes the underlying
   message and the attempt count.

2. **Per-attempt total timeout + stall detector.** Total timeout
   defaults to 5 minutes per attempt (`DEEPSEEK_TUI_DOWNLOAD_TIMEOUT_MS`,
   alias `DEEPSEEK_DOWNLOAD_TIMEOUT_MS`). A stall detector aborts
   the request when no bytes arrive for 30 s
   (`DEEPSEEK_TUI_DOWNLOAD_STALL_MS`, alias
   `DEEPSEEK_DOWNLOAD_STALL_MS`) so a hung connection doesn't waste
   the whole timeout. Both budgets are surfaced in the error so the
   user can dial them up if they're on a slow pipe.

3. **HTTPS_PROXY / HTTP_PROXY support — pure Node, no new
   dependencies.** Detects `HTTPS_PROXY` / `HTTP_PROXY` (and the
   lowercase variants) and routes through the proxy via CONNECT
   tunneling. `NO_PROXY` exclusion list honored, with `*` and dotted-
   suffix matching. Proxy auth via standard `user:pass@` URL form is
   passed through as `Proxy-Authorization: Basic ...`. Pure-Node
   implementation using `net` + `tls` + `http` + `https` builtins —
   no `https-proxy-agent` dependency added.

4. **Download progress indicator.** Writes to stderr every ~1 MB
   or every 2 s in TTY mode using `\r` to overwrite a single line.
   Non-TTY mode (CI, piped) emits one line per 5 MB so logs stay
   reasonable. Suppressed when `DEEPSEEK_TUI_QUIET_INSTALL=1` or
   when `npm_config_loglevel` is `silent` or `error`. Falls back to
   `N MB downloaded` when the response has no `Content-Length`.

Public API unchanged: existing callers of `getBinaryPath` and `run`
keep working identically when no new env vars are set. The escape
hatch `DEEPSEEK_TUI_DISABLE_INSTALL=1` still exits cleanly.

Verified locally:

* `node -c install.js` and module-load syntax checks.
* `DEEPSEEK_TUI_FORCE_DOWNLOAD=1 DEEPSEEK_TUI_VERSION=0.8.10 node
  install.js` — real GitHub Releases download succeeded with
  visible progress, both binaries landed.
* `HTTPS_PROXY=http://invalid.proxy.local:9999 ... node install.js`
  — proxy path exercised, fails cleanly with the bad host named
  in the error message after retries exhausted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-04 22:19:34 -05:00
parent d0e95f23b0
commit 229f02ea2c
+740 -36
View File
@@ -1,10 +1,12 @@
const fs = require("fs");
const https = require("https");
const http = require("http");
const net = require("net");
const tls = require("tls");
const crypto = require("crypto");
const { URL } = require("url");
const { mkdir, chmod, stat, rename, readFile, unlink, writeFile } = fs.promises;
const { createWriteStream } = fs;
const { pipeline } = require("stream/promises");
const path = require("path");
const {
@@ -16,6 +18,46 @@ const {
const { preflightGlibc } = require("./preflight-glibc");
const pkg = require("../package.json");
const DEFAULT_TIMEOUT_MS = 300_000; // 5 minutes per attempt
const DEFAULT_STALL_MS = 30_000; // abort if no bytes for 30s
const MAX_ATTEMPTS = 5;
const BASE_BACKOFF_MS = 1_000;
const RETRYABLE_NET_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",
"ETIMEDOUT",
"EAI_AGAIN",
"ENETUNREACH",
"EHOSTUNREACH",
"EPIPE",
"ECONNABORTED",
]);
class NonRetryableError extends Error {
constructor(message) {
super(message);
this.name = "NonRetryableError";
this.nonRetryable = true;
}
}
class HttpStatusError extends Error {
constructor(status, url) {
super(`Request failed with status ${status}: ${url}`);
this.name = "HttpStatusError";
this.status = status;
}
}
class DownloadTimeoutError extends Error {
constructor(message) {
super(message);
this.name = "DownloadTimeoutError";
this.code = "EDOWNLOADTIMEOUT";
}
}
function resolvePackageVersion() {
const configuredVersion =
process.env.DEEPSEEK_TUI_VERSION ||
@@ -44,45 +86,705 @@ function binaryPaths() {
};
}
async function httpGet(url) {
const client = url.startsWith("https:") ? https : http;
const response = await new Promise((resolve, reject) => {
client.get(url, (res) => {
const status = res.statusCode || 0;
if (status >= 300 && status < 400 && res.headers.location) {
resolve({ redirect: res.headers.location, response: null });
return;
}
if (status !== 200) {
reject(new Error(`Request failed with status ${status}: ${url}`));
return;
}
resolve({ redirect: null, response: res });
}).on("error", reject);
});
return response;
// ────────────────────────────────────────────────────────────────────────────
// Logging / progress
// ────────────────────────────────────────────────────────────────────────────
function isQuietInstall() {
if (process.env.DEEPSEEK_TUI_QUIET_INSTALL === "1") {
return true;
}
const level = (process.env.npm_config_loglevel || "").toLowerCase();
return level === "silent" || level === "error";
}
async function download(url, destination) {
const resolved = await httpGet(url);
if (resolved.redirect) {
return download(resolved.redirect, destination);
function logInfo(message) {
if (isQuietInstall()) {
return;
}
process.stderr.write(`deepseek-tui: ${message}\n`);
}
function envInt(name, fallback) {
const raw = process.env[name];
if (!raw) {
return fallback;
}
const parsed = Number.parseInt(String(raw).trim(), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return parsed;
}
function downloadTimeoutMs() {
return envInt(
"DEEPSEEK_TUI_DOWNLOAD_TIMEOUT_MS",
envInt("DEEPSEEK_DOWNLOAD_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
);
}
function downloadStallMs() {
return envInt(
"DEEPSEEK_TUI_DOWNLOAD_STALL_MS",
envInt("DEEPSEEK_DOWNLOAD_STALL_MS", DEFAULT_STALL_MS),
);
}
function formatMb(bytes) {
return (bytes / (1024 * 1024)).toFixed(0);
}
function createProgressReporter(assetName, totalBytes) {
if (isQuietInstall()) {
return { onChunk: () => {}, finish: () => {} };
}
const isTty = !!process.stderr.isTTY;
const interactive = isTty;
const tickBytes = interactive ? 1 * 1024 * 1024 : 5 * 1024 * 1024;
const tickMs = 2_000;
let received = 0;
let lastBytesPrinted = 0;
let lastTimePrinted = 0;
let everPrinted = false;
const render = (final) => {
if (totalBytes && totalBytes > 0) {
const pct = Math.min(100, Math.round((received / totalBytes) * 100));
const line = `deepseek-tui: downloading ${assetName}: ${formatMb(received)} / ${formatMb(totalBytes)} MB (${pct}%)`;
if (interactive) {
process.stderr.write(`${line}\r`);
} else {
process.stderr.write(`${line}\n`);
}
} else {
const line = `deepseek-tui: downloading ${assetName}: ${formatMb(received)} MB downloaded`;
if (interactive) {
process.stderr.write(`${line}\r`);
} else {
process.stderr.write(`${line}\n`);
}
}
everPrinted = true;
lastBytesPrinted = received;
lastTimePrinted = Date.now();
};
return {
onChunk(chunkLen) {
received += chunkLen;
const now = Date.now();
if (
received - lastBytesPrinted >= tickBytes ||
(interactive && now - lastTimePrinted >= tickMs)
) {
render(false);
}
},
finish() {
// Final line — always render once.
render(true);
if (interactive && everPrinted) {
// Move past the carriage-return line and emit a "done" footer.
process.stderr.write("\n");
}
process.stderr.write(`deepseek-tui: ${assetName} ... done.\n`);
},
};
}
// ────────────────────────────────────────────────────────────────────────────
// Proxy support (HTTPS_PROXY / HTTP_PROXY / NO_PROXY) — pure Node, CONNECT
// tunnel + TLS upgrade for HTTPS targets.
// ────────────────────────────────────────────────────────────────────────────
function getProxyUrl(targetUrl) {
const isHttps = targetUrl.protocol === "https:";
const candidates = isHttps
? ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]
: ["HTTP_PROXY", "http_proxy"];
for (const name of candidates) {
const raw = process.env[name];
if (raw && String(raw).trim() !== "") {
return String(raw).trim();
}
}
return null;
}
function shouldBypassProxy(host) {
const raw = process.env.NO_PROXY || process.env.no_proxy;
if (!raw) {
return false;
}
const lower = String(host).toLowerCase();
for (const part of String(raw).split(",")) {
const entry = part.trim().toLowerCase();
if (!entry) {
continue;
}
if (entry === "*") {
return true;
}
// Strip leading dot and any explicit port.
const stripped = entry.replace(/^\./, "").replace(/:.*$/, "");
if (!stripped) {
continue;
}
if (lower === stripped || lower.endsWith(`.${stripped}`)) {
return true;
}
}
return false;
}
function parseProxy(proxyStr) {
// Accept "http://user:pass@host:port" and bare "host:port".
const normalized = /^[a-z][a-z0-9+\-.]*:\/\//i.test(proxyStr)
? proxyStr
: `http://${proxyStr}`;
const u = new URL(normalized);
const port = u.port
? Number.parseInt(u.port, 10)
: u.protocol === "https:"
? 443
: 80;
let auth = null;
if (u.username) {
const user = decodeURIComponent(u.username);
const pass = u.password ? decodeURIComponent(u.password) : "";
auth = Buffer.from(`${user}:${pass}`).toString("base64");
}
return {
protocol: u.protocol,
host: u.hostname,
port,
auth,
raw: proxyStr,
};
}
function connectThroughProxy(proxy, targetHost, targetPort, timeoutMs) {
return new Promise((resolve, reject) => {
const socket = net.connect({ host: proxy.host, port: proxy.port });
let settled = false;
const fail = (err) => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// ignore
}
reject(err);
};
const timer = timeoutMs > 0
? setTimeout(() => fail(new DownloadTimeoutError(
`proxy CONNECT to ${proxy.host}:${proxy.port} timed out after ${timeoutMs} ms`,
)), timeoutMs)
: null;
socket.once("error", (err) => {
if (timer) clearTimeout(timer);
// Surface proxy host so the user can fix it.
const wrapped = new Error(
`proxy connection failed (${proxy.host}:${proxy.port}): ${err.message}`,
);
wrapped.code = err.code;
fail(wrapped);
});
socket.once("connect", () => {
const lines = [
`CONNECT ${targetHost}:${targetPort} HTTP/1.1`,
`Host: ${targetHost}:${targetPort}`,
"User-Agent: deepseek-tui-installer",
"Proxy-Connection: keep-alive",
];
if (proxy.auth) {
lines.push(`Proxy-Authorization: Basic ${proxy.auth}`);
}
const req = `${lines.join("\r\n")}\r\n\r\n`;
let buf = Buffer.alloc(0);
const onData = (chunk) => {
buf = Buffer.concat([buf, chunk]);
const idx = buf.indexOf("\r\n\r\n");
if (idx === -1) {
if (buf.length > 16 * 1024) {
socket.removeListener("data", onData);
fail(new Error(
`proxy ${proxy.host}:${proxy.port} returned an oversized response header`,
));
}
return;
}
socket.removeListener("data", onData);
const head = buf.slice(0, idx).toString("utf8");
const firstLine = head.split(/\r?\n/, 1)[0] || "";
const m = firstLine.match(/^HTTP\/\d\.\d\s+(\d{3})/);
if (!m) {
fail(new Error(`proxy ${proxy.host}:${proxy.port} returned invalid CONNECT reply: ${firstLine}`));
return;
}
const code = Number.parseInt(m[1], 10);
if (code !== 200) {
fail(new Error(
`proxy ${proxy.host}:${proxy.port} refused CONNECT to ${targetHost}:${targetPort}: HTTP ${code}`,
));
return;
}
if (timer) clearTimeout(timer);
if (settled) return;
settled = true;
// Any bytes past the header belong to the tunneled stream — but in
// practice CONNECT 200 has no body; if it did, we'd lose those bytes
// here. Keep it simple: trust well-behaved proxies.
resolve(socket);
};
socket.on("data", onData);
socket.write(req, "utf8");
});
});
}
// ────────────────────────────────────────────────────────────────────────────
// HTTP request with timeout, stall detection, and proxy support.
// ────────────────────────────────────────────────────────────────────────────
function httpRequest(rawUrl, opts = {}) {
const totalTimeoutMs = opts.totalTimeoutMs ?? downloadTimeoutMs();
const stallMs = opts.stallMs ?? downloadStallMs();
return new Promise((resolve, reject) => {
let url;
try {
url = new URL(rawUrl);
} catch (err) {
reject(new NonRetryableError(`Invalid URL: ${rawUrl} (${err.message})`));
return;
}
if (url.protocol !== "https:" && url.protocol !== "http:") {
reject(new NonRetryableError(`Unsupported protocol: ${url.protocol}`));
return;
}
const proxyStr = !shouldBypassProxy(url.hostname) ? getProxyUrl(url) : null;
const isHttps = url.protocol === "https:";
const port = url.port
? Number.parseInt(url.port, 10)
: isHttps
? 443
: 80;
let totalTimer = null;
let stallTimer = null;
let settled = false;
let req = null;
let res = null;
const cleanup = () => {
if (totalTimer) {
clearTimeout(totalTimer);
totalTimer = null;
}
if (stallTimer) {
clearTimeout(stallTimer);
stallTimer = null;
}
};
const fail = (err) => {
if (settled) return;
settled = true;
cleanup();
try {
if (req && !req.destroyed) req.destroy();
} catch {
// ignore
}
try {
if (res && !res.destroyed) res.destroy();
} catch {
// ignore
}
reject(err);
};
if (totalTimeoutMs > 0) {
totalTimer = setTimeout(() => {
fail(new DownloadTimeoutError(
`download exceeded total timeout of ${totalTimeoutMs} ms ` +
`(set DEEPSEEK_TUI_DOWNLOAD_TIMEOUT_MS to raise it; current stall budget is ${stallMs} ms)`,
));
}, totalTimeoutMs);
}
const armStallTimer = () => {
if (stallMs <= 0) return;
if (stallTimer) clearTimeout(stallTimer);
stallTimer = setTimeout(() => {
fail(new DownloadTimeoutError(
`download stalled — no bytes received for ${stallMs} ms ` +
`(set DEEPSEEK_TUI_DOWNLOAD_STALL_MS to raise it; total budget is ${totalTimeoutMs} ms)`,
));
}, stallMs);
};
const launch = (socket) => {
const reqOptions = {
method: "GET",
host: url.hostname,
port,
path: `${url.pathname}${url.search || ""}`,
headers: {
Host: url.host,
"User-Agent": "deepseek-tui-installer",
Accept: "*/*",
Connection: "close",
},
};
if (socket) {
reqOptions.createConnection = () => socket;
if (isHttps) {
// Wrap raw TCP socket from CONNECT in TLS.
const tlsSocket = tls.connect({
socket,
servername: url.hostname,
ALPNProtocols: ["http/1.1"],
});
tlsSocket.once("error", (err) => fail(err));
reqOptions.createConnection = () => tlsSocket;
}
}
const client = isHttps ? https : http;
try {
req = client.request(reqOptions, (response) => {
res = response;
armStallTimer();
response.on("data", () => {
armStallTimer();
});
response.on("end", () => {
cleanup();
});
response.on("error", (err) => fail(err));
const status = response.statusCode || 0;
if (status >= 300 && status < 400 && response.headers.location) {
cleanup();
settled = true;
response.resume();
resolve({ redirect: response.headers.location, response: null });
return;
}
if (status < 200 || status >= 300) {
const err = new HttpStatusError(status, rawUrl);
// 4xx: non-retryable; 5xx: retryable.
if (status >= 400 && status < 500) {
err.nonRetryable = true;
}
fail(err);
return;
}
if (settled) return;
settled = true;
// Hand the live response stream to the caller.
resolve({ redirect: null, response });
});
req.once("error", (err) => fail(err));
req.once("socket", (s) => {
// Belt-and-suspenders: surface socket-level errors quickly.
s.once("error", (err) => fail(err));
});
req.end();
} catch (err) {
fail(err);
}
};
if (proxyStr) {
let proxy;
try {
proxy = parseProxy(proxyStr);
} catch (err) {
fail(new NonRetryableError(
`Invalid proxy URL "${proxyStr}": ${err.message}`,
));
return;
}
if (!isHttps) {
// Plain HTTP through proxy — send absolute URI, no CONNECT.
const client = http;
try {
req = client.request(
{
host: proxy.host,
port: proxy.port,
method: "GET",
path: rawUrl,
headers: {
Host: url.host,
"User-Agent": "deepseek-tui-installer",
Accept: "*/*",
Connection: "close",
...(proxy.auth ? { "Proxy-Authorization": `Basic ${proxy.auth}` } : {}),
},
},
(response) => {
res = response;
armStallTimer();
response.on("data", () => armStallTimer());
response.on("end", () => cleanup());
response.on("error", (err) => fail(err));
const status = response.statusCode || 0;
if (status >= 300 && status < 400 && response.headers.location) {
cleanup();
settled = true;
response.resume();
resolve({ redirect: response.headers.location, response: null });
return;
}
if (status < 200 || status >= 300) {
const err = new HttpStatusError(status, rawUrl);
if (status >= 400 && status < 500) err.nonRetryable = true;
fail(err);
return;
}
if (settled) return;
settled = true;
resolve({ redirect: null, response });
},
);
req.once("error", (err) => fail(err));
req.end();
} catch (err) {
fail(err);
}
return;
}
// HTTPS through proxy: CONNECT tunnel + TLS upgrade.
connectThroughProxy(proxy, url.hostname, port, Math.max(stallMs, 5_000))
.then((tcpSocket) => {
if (settled) {
try { tcpSocket.destroy(); } catch { /* ignore */ }
return;
}
const tlsSocket = tls.connect({
socket: tcpSocket,
servername: url.hostname,
ALPNProtocols: ["http/1.1"],
});
tlsSocket.once("error", (err) => fail(err));
tlsSocket.once("secureConnect", () => {
if (settled) {
try { tlsSocket.destroy(); } catch { /* ignore */ }
return;
}
const reqOptions = {
method: "GET",
createConnection: () => tlsSocket,
path: `${url.pathname}${url.search || ""}`,
headers: {
Host: url.host,
"User-Agent": "deepseek-tui-installer",
Accept: "*/*",
Connection: "close",
},
};
try {
req = https.request(reqOptions, (response) => {
res = response;
armStallTimer();
response.on("data", () => armStallTimer());
response.on("end", () => cleanup());
response.on("error", (err) => fail(err));
const status = response.statusCode || 0;
if (status >= 300 && status < 400 && response.headers.location) {
cleanup();
settled = true;
response.resume();
resolve({ redirect: response.headers.location, response: null });
return;
}
if (status < 200 || status >= 300) {
const err = new HttpStatusError(status, rawUrl);
if (status >= 400 && status < 500) err.nonRetryable = true;
fail(err);
return;
}
if (settled) return;
settled = true;
resolve({ redirect: null, response });
});
req.once("error", (err) => fail(err));
req.end();
} catch (err) {
fail(err);
}
});
})
.catch((err) => fail(err));
return;
}
// No proxy — direct connection.
launch(null);
});
}
// ────────────────────────────────────────────────────────────────────────────
// Retry wrapper
// ────────────────────────────────────────────────────────────────────────────
function isRetryable(err) {
if (!err) return false;
if (err.nonRetryable) return false;
if (err instanceof NonRetryableError) return false;
if (err instanceof DownloadTimeoutError) return true;
if (err instanceof HttpStatusError) {
return err.status >= 500;
}
if (err.code && RETRYABLE_NET_CODES.has(err.code)) return true;
// Network-flavored messages we may see without a code.
const msg = String(err.message || "").toLowerCase();
if (msg.includes("network") && msg.includes("unreachable")) return true;
if (msg.includes("socket hang up")) return true;
if (msg.includes("aborted")) return true;
return false;
}
function backoffDelay(attempt) {
// attempt is 1-indexed; first retry waits ~1s.
const base = BASE_BACKOFF_MS * 2 ** (attempt - 1);
const jitter = (Math.random() * 0.4 - 0.2) * base; // ±20%
return Math.max(0, Math.round(base + jitter));
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function withRetry(label, fn) {
let lastErr;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
return await fn(attempt);
} catch (err) {
lastErr = err;
if (!isRetryable(err) || attempt === MAX_ATTEMPTS) {
break;
}
const wait = backoffDelay(attempt);
logInfo(
`${label} failed (attempt ${attempt}/${MAX_ATTEMPTS}): ${err.message}; retrying in ${wait} ms`,
);
await sleep(wait);
}
}
const msg = lastErr && lastErr.message ? lastErr.message : String(lastErr);
const wrapped = new Error(
`${label} failed after ${MAX_ATTEMPTS} attempt(s): ${msg}`,
);
if (lastErr && lastErr.stack) {
wrapped.cause = lastErr;
}
throw wrapped;
}
// ────────────────────────────────────────────────────────────────────────────
// Public download primitives (now retry + progress aware)
// ────────────────────────────────────────────────────────────────────────────
async function followRedirects(url, opts) {
const maxRedirects = 10;
let current = url;
for (let hop = 0; hop < maxRedirects; hop++) {
const result = await httpRequest(current, opts);
if (result.redirect) {
try {
current = new URL(result.redirect, current).toString();
} catch {
current = result.redirect;
}
continue;
}
return result;
}
throw new NonRetryableError(`too many redirects starting at ${url}`);
}
function streamToFile(response, destination, progress) {
return new Promise((resolve, reject) => {
const sink = createWriteStream(destination);
let done = false;
const finish = (err) => {
if (done) return;
done = true;
if (err) {
sink.destroy();
reject(err);
} else {
resolve();
}
};
response.on("data", (chunk) => {
if (progress) progress.onChunk(chunk.length);
});
response.on("error", (err) => finish(err));
sink.on("error", (err) => finish(err));
sink.on("finish", () => finish(null));
response.pipe(sink);
});
}
async function download(url, destination, options = {}) {
await mkdir(path.dirname(destination), { recursive: true });
await pipeline(resolved.response, createWriteStream(destination));
const assetName = options.assetName || path.basename(destination);
await withRetry(`download ${assetName}`, async (attempt) => {
const result = await followRedirects(url, {
totalTimeoutMs: downloadTimeoutMs(),
stallMs: downloadStallMs(),
});
const response = result.response;
const lenHeader = response.headers["content-length"];
const total = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
const progress = createProgressReporter(assetName, Number.isFinite(total) ? total : 0);
if (attempt > 1) {
logInfo(`retry attempt ${attempt}/${MAX_ATTEMPTS} for ${assetName}`);
}
try {
await streamToFile(response, destination, progress);
} catch (err) {
// Ensure we don't leave a partial file confusing future attempts.
try {
await unlink(destination);
} catch {
// ignore
}
throw err;
}
progress.finish();
});
}
async function downloadText(url) {
const resolved = await httpGet(url);
if (resolved.redirect) {
return downloadText(resolved.redirect);
}
const chunks = [];
resolved.response.setEncoding("utf8");
for await (const chunk of resolved.response) {
chunks.push(chunk);
}
return chunks.join("");
return withRetry(`fetch ${url}`, async () => {
const result = await followRedirects(url, {
totalTimeoutMs: downloadTimeoutMs(),
stallMs: downloadStallMs(),
});
const response = result.response;
const chunks = [];
response.setEncoding("utf8");
for await (const chunk of response) {
chunks.push(chunk);
}
return chunks.join("");
});
}
async function readLocalVersion(file) {
@@ -122,11 +824,13 @@ async function sha256File(filePath) {
async function verifyChecksum(filePath, assetName, checksums) {
const expected = checksums.get(assetName);
if (!expected) {
throw new Error(`Checksum manifest is missing ${assetName}`);
throw new NonRetryableError(`Checksum manifest is missing ${assetName}`);
}
const actual = await sha256File(filePath);
if (actual !== expected) {
throw new Error(
// Bytes are corrupted; another fetch is unlikely to help without a fix
// upstream. Mark non-retryable.
throw new NonRetryableError(
`Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`,
);
}
@@ -152,7 +856,7 @@ async function ensureBinary(targetPath, assetName, version, repo, getChecksums)
const checksums = await getChecksums();
const url = releaseAssetUrl(assetName, version, repo);
const destination = `${targetPath}.${process.pid}.${Date.now()}.download`;
await download(url, destination);
await download(url, destination, { assetName });
try {
await verifyChecksum(destination, assetName, checksums);
preflightGlibc(destination);