mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-28 21:01:59 +01:00
Retry installer fetches after interrupted responses
This commit is contained in:
1 parent
3d06824f4d
commit
082af24115
4 files changed
+65
-30
No files matched your search
+1
-26
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user