diff --git a/app/build/fetch-deps.js b/app/build/fetch-deps.js index 79466f8..04e96b1 100644 --- a/app/build/fetch-deps.js +++ b/app/build/fetch-deps.js @@ -7,10 +7,9 @@ // node build/fetch-deps.js mac arm64 x64 | win x64 | linux x64 arm64 const crypto = require("crypto"); const fs = require("fs"); -const https = require("https"); const path = require("path"); const { execFileSync } = require("child_process"); -const { fetchWithRetry } = require("./fetch-retry"); +const { get, fetchWithRetry } = require("./fetch-retry"); const PY = "3.12.14+20260924"; const PY_URL = (triple) => "https://github.com/astral-sh/python-build-standalone/releases/download/" @@ -47,30 +46,6 @@ const PRUNE = [ .flatMap((d) => [`lib/python3.12/${d}`, `Lib/${d}`]), ]; -function get(url, redirects = 5) { - return new Promise((resolve, reject) => { - https.get(url, { timeout: 60000 }, (res) => { - if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - res.resume(); - if (!redirects) return reject(new Error(`${url}: too many redirects`)); - let next; - try { next = new URL(res.headers.location, url).href; } - catch { return reject(new Error(`${url}: bad redirect ${res.headers.location}`)); } - return resolve(get(next, redirects - 1)); - } - if (res.statusCode !== 200) { - res.resume(); - const error = new Error(`${url}: HTTP ${res.statusCode}`); - error.statusCode = res.statusCode; - return reject(error); - } - const chunks = []; - res.on("data", (c) => chunks.push(c)); - res.on("end", () => resolve(Buffer.concat(chunks))); - }).on("timeout", function () { this.destroy(new Error(`${url}: timed out`)); }).on("error", reject); - }); -} - async function download(url, sha256, file) { const data = await fetchWithRetry(get, url); const sum = crypto.createHash("sha256").update(data).digest("hex"); diff --git a/app/build/fetch-retry.js b/app/build/fetch-retry.js index 9641ca9..94cb5ef 100644 --- a/app/build/fetch-retry.js +++ b/app/build/fetch-retry.js @@ -1,3 +1,31 @@ +const https = require("https"); + +function get(url, redirects = 5, transport = https) { + return new Promise((resolve, reject) => { + transport.get(url, { timeout: 60000 }, (res) => { + res.on("error", reject); + res.on("aborted", () => reject(new Error(`${url}: response aborted`))); + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); + if (!redirects) return reject(new Error(`${url}: too many redirects`)); + let next; + try { next = new URL(res.headers.location, url).href; } + catch { return reject(new Error(`${url}: bad redirect ${res.headers.location}`)); } + return resolve(get(next, redirects - 1, transport)); + } + if (res.statusCode !== 200) { + res.resume(); + const error = new Error(`${url}: HTTP ${res.statusCode}`); + error.statusCode = res.statusCode; + return reject(error); + } + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => resolve(Buffer.concat(chunks))); + }).on("timeout", function () { this.destroy(new Error(`${url}: timed out`)); }).on("error", reject); + }); +} + // Release assets are pinned by SHA-256 at the call site. Retry only transport // failures and transient HTTP responses; the caller still rejects bad bytes. async function fetchWithRetry(fetcher, url, pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) { @@ -12,4 +40,4 @@ async function fetchWithRetry(fetcher, url, pause = (ms) => new Promise((resolve } } -module.exports = { fetchWithRetry }; +module.exports = { get, fetchWithRetry }; diff --git a/app/test/fetch-retry.test.js b/app/test/fetch-retry.test.js index 7e8cc64..9fb7fd7 100644 --- a/app/test/fetch-retry.test.js +++ b/app/test/fetch-retry.test.js @@ -1,6 +1,7 @@ const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); const test = require('node:test'); -const { fetchWithRetry } = require('../build/fetch-retry'); +const { get, fetchWithRetry } = require('../build/fetch-retry'); test('installer fetch recovers a dropped connection without hiding final failure', async () => { let calls = 0; @@ -43,3 +44,33 @@ test('installer fetch retries a transient HTTP response', async () => { assert.equal(bytes.toString(), 'pinned bytes'); assert.equal(calls, 2); }); + +test('installer fetch retries a response aborted after headers and partial bytes', async () => { + let calls = 0; + const transport = { + get(_url, _options, callback) { + const request = new EventEmitter(); + queueMicrotask(() => { + const response = new EventEmitter(); + response.statusCode = 200; + response.headers = {}; + callback(response); + if (++calls === 1) { + response.emit('data', Buffer.from('partial')); + response.emit('aborted'); + } else { + response.emit('data', Buffer.from('complete')); + response.emit('end'); + } + }); + return request; + }, + }; + const bytes = await fetchWithRetry( + (url) => get(url, 5, transport), + 'https://example.invalid/package', + async () => {}, + ); + assert.equal(bytes.toString(), 'complete'); + assert.equal(calls, 2); +}); diff --git a/docs/evidence/installer-fetch-retry-2026-10-28.md b/docs/evidence/installer-fetch-retry-2026-10-28.md index 8df5458..5ae5ecd 100644 --- a/docs/evidence/installer-fetch-retry-2026-10-28.md +++ b/docs/evidence/installer-fetch-retry-2026-10-28.md @@ -13,8 +13,9 @@ other than 408/429 fail immediately. The existing pinned SHA-256 comparison still happens after download and before writing package bytes, so altered content fails without retry or installation. -Local Node 24 checks passed: three retry cases (recovery, exhausted retries, -permanent HTTP error and HTTP 503), all six app test files, script syntax and +Local Node 24 checks passed: retry recovery, exhausted retries, permanent HTTP +error, HTTP 503 and a response aborted after partial bytes; all six app test +files, script syntax and `git diff --check`. Hosted exact-head CI and installer preview remain required before merge/release. No installer was published, and this test makes no claim about a physical Mac or Windows install.