Files
openwhispr/scripts/lib/download-utils.js
Chad Piha c4ae6071e8 fix(scripts): let download scripts exit after following a redirect (#2410)
* fix(scripts): let download scripts exit after following a redirect

downloadFile followed redirects without reading the redirect body. On
Node 24 the global agent keeps sockets alive and only frees (and unrefs)
a socket once its response ends, so the unread 302 from huggingface.co
pinned the socket and kept `download-whisper-vad-model.js` alive for
minutes, stalling the `predev:main` chain before Electron started.

Drain redirect bodies in downloadFile and fetchJson, drain fetchJson's
non-200 bodies, and destroy a timed-out fetchJson request instead of only
rejecting.

* fix(scripts): keep a followed redirect from cancelling the live download

downloadFile's error and timeout handlers acted on whichever request was
current, so a reset on a redirect whose body was still draining destroyed
the request that replaced it and forced a retry. Each handler now only acts
on its own request; a stale one that times out is just destroyed.

Also adds a test for fetchJson's timeout closing the stalled request.
2026-09-30 00:16:43 +02:00

421 lines
12 KiB
JavaScript

const https = require("https");
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
const REQUEST_TIMEOUT = 30000;
const MAX_RETRIES = 3;
const RETRY_DELAY = 2000;
const MAX_REDIRECTS = 5;
// Node's global agent keeps sockets alive, and a socket only returns to the pool (where it
// stops holding the event loop open) once its response has been read to the end. A redirect
// or error body we never read pins the socket until the server drops it, often for minutes.
function discardBody(response) {
response.resume();
}
/**
* Fetch JSON from a URL with proper error handling.
* @param {string} url - URL to fetch
* @param {number} [redirectCount=0] - Current redirect count (internal use)
* @returns {Promise<object>} - Parsed JSON response
*/
function fetchJson(url, redirectCount = 0) {
return new Promise((resolve, reject) => {
if (redirectCount > MAX_REDIRECTS) {
reject(new Error("Too many redirects"));
return;
}
const headers = {
"User-Agent": "OpenWhispr-Downloader",
Accept: "application/vnd.github+json",
};
// Use GitHub token if available (increases rate limit from 60 to 5000/hour)
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const options = {
headers,
timeout: REQUEST_TIMEOUT,
};
https
.get(url, options, (res) => {
if ([301, 302, 303, 307, 308].includes(res.statusCode)) {
discardBody(res);
const location = res.headers.location;
if (!location) {
reject(new Error("Redirect without location header"));
return;
}
const redirectUrl = location.startsWith("/") ? new URL(location, url).href : location;
fetchJson(redirectUrl, redirectCount + 1)
.then(resolve)
.catch(reject);
return;
}
if (res.statusCode !== 200) {
discardBody(res);
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error(`Failed to parse JSON: ${e.message}`));
}
});
res.on("error", reject);
})
.on("error", reject)
.on("timeout", function onTimeout() {
// Destroying rejects through the "error" handler and frees the stalled socket.
this.destroy(new Error("Request timeout"));
});
});
}
/**
* Fetch a release from a GitHub repository.
* @param {string} repo - Repository in "owner/repo" format
* @param {object} options - Options
* @param {string} [options.tag] - Exact tag to fetch (works for any release age, no pagination)
* @param {string} [options.tagPrefix] - Latest release whose tag starts with this prefix (searches the 50 most recent only)
* @param {boolean} [options.includePrerelease=false] - Include prereleases (tagPrefix only)
* @returns {Promise<{tag: string, assets: Array<{name: string, url: string}>, url: string} | null>}
*/
async function fetchLatestRelease(repo, options = {}) {
const { tag, tagPrefix, includePrerelease = false } = options;
try {
if (tag) {
const url = `https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`;
const release = await fetchJson(url);
return formatRelease(release);
}
if (!tagPrefix) {
const url = `https://api.github.com/repos/${repo}/releases/latest`;
const release = await fetchJson(url);
return formatRelease(release);
}
const url = `https://api.github.com/repos/${repo}/releases?per_page=50`;
const releases = await fetchJson(url);
if (!Array.isArray(releases)) {
return null;
}
// Find the latest release matching the prefix
for (const release of releases) {
if (release.draft) continue;
if (!includePrerelease && release.prerelease) continue;
if (release.tag_name && release.tag_name.startsWith(tagPrefix)) {
return formatRelease(release);
}
}
return null;
} catch (error) {
console.error(` Failed to fetch latest release for ${repo}: ${error.message}`);
return null;
}
}
/**
* Format a GitHub release response into a simplified object.
* @param {object} release - GitHub release API response
* @returns {{tag: string, assets: Array<{name: string, url: string}>, url: string}}
*/
function formatRelease(release) {
return {
tag: release.tag_name,
url: release.html_url,
assets: (release.assets || []).map((asset) => ({
name: asset.name,
url: asset.browser_download_url,
})),
};
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function downloadFile(url, dest, retryCount = 0) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(dest);
let activeRequest = null;
const cleanup = () => {
if (activeRequest) {
activeRequest.destroy();
activeRequest = null;
}
file.close();
};
const request = (currentUrl, redirectCount = 0) => {
if (redirectCount > MAX_REDIRECTS) {
cleanup();
reject(new Error("Too many redirects"));
return;
}
const req = https.get(currentUrl, (response) => {
if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
discardBody(response);
const location = response.headers.location;
if (!location) {
cleanup();
reject(new Error("Redirect without location header"));
return;
}
const redirectUrl = location.startsWith("/")
? new URL(location, currentUrl).href
: location;
request(redirectUrl, redirectCount + 1);
return;
}
if (response.statusCode !== 200) {
cleanup();
reject(new Error(`HTTP ${response.statusCode}`));
return;
}
const total = parseInt(response.headers["content-length"], 10);
let downloaded = 0;
response.on("data", (chunk) => {
downloaded += chunk.length;
const pct = total ? Math.round((downloaded / total) * 100) : 0;
process.stdout.write(`\r Downloading: ${pct}%`);
});
response.on("error", (err) => {
cleanup();
reject(err);
});
response.pipe(file);
file.on("finish", () => {
file.close();
console.log(" Done");
resolve();
});
file.on("error", (err) => {
cleanup();
reject(err);
});
});
activeRequest = req;
// A redirect we already followed can still error or time out while its body drains;
// that must not tear down the request that replaced it.
req.on("error", (err) => {
if (req !== activeRequest) return;
cleanup();
reject(err);
});
req.setTimeout(REQUEST_TIMEOUT, () => {
if (req !== activeRequest) {
req.destroy();
return;
}
cleanup();
reject(new Error("Connection timed out"));
});
};
request(url);
}).catch(async (error) => {
const isTransient =
error.message.includes("timed out") ||
error.code === "ECONNRESET" ||
error.code === "ETIMEDOUT";
if (isTransient && retryCount < MAX_RETRIES) {
console.log(`\n Retry ${retryCount + 1}/${MAX_RETRIES}: ${error.message}`);
await sleep(RETRY_DELAY);
if (fs.existsSync(dest)) {
fs.unlinkSync(dest);
}
return downloadFile(url, dest, retryCount + 1);
}
throw error;
});
}
async function extractZip(zipPath, destDir) {
if (process.platform === "win32") {
// Use unzipper package on Windows for better path handling
const unzipper = require("unzipper");
await fs
.createReadStream(zipPath)
.pipe(unzipper.Extract({ path: destDir }))
.promise();
} else {
execSync(`unzip -o "${zipPath}" -d "${destDir}"`, { stdio: "inherit" });
}
}
function extractTarGz(tarPath, destDir) {
execSync(`tar -xzf "${tarPath}" -C "${destDir}"`, { stdio: "inherit" });
}
async function extractArchive(archivePath, destDir) {
if (archivePath.endsWith(".tar.gz") || archivePath.endsWith(".tgz")) {
extractTarGz(archivePath, destDir);
} else {
await extractZip(archivePath, destDir);
}
}
function findBinaryInDir(dir, binaryName, maxDepth = 5, currentDepth = 0) {
if (currentDepth >= maxDepth) return null;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const found = findBinaryInDir(fullPath, binaryName, maxDepth, currentDepth + 1);
if (found) return found;
} else if (entry.name === binaryName) {
return fullPath;
}
}
return null;
}
function parseArgs() {
const args = process.argv;
let targetPlatform = process.env.TARGET_PLATFORM || process.platform;
let targetArch = process.env.TARGET_ARCH || process.arch;
// CLI args override env vars
const platformIndex = args.indexOf("--platform");
if (platformIndex !== -1 && args[platformIndex + 1]) {
targetPlatform = args[platformIndex + 1];
}
const archIndex = args.indexOf("--arch");
if (archIndex !== -1 && args[archIndex + 1]) {
targetArch = args[archIndex + 1];
}
return {
targetPlatform,
targetArch,
platformArch: `${targetPlatform}-${targetArch}`,
isCurrent: args.includes("--current"),
isAll: args.includes("--all"),
isForce: args.includes("--force"),
shouldCleanup:
args.includes("--clean") ||
process.env.CI === "true" ||
process.env.GITHUB_ACTIONS === "true",
};
}
function setExecutable(filePath) {
if (process.platform !== "win32") {
fs.chmodSync(filePath, 0o755);
}
}
function matchesPattern(filename, pattern) {
if (pattern === "*.dylib") {
return filename.endsWith(".dylib");
} else if (pattern === "*.dll") {
return filename.endsWith(".dll");
} else if (pattern === "*.so*") {
return /\.so(\.\d+)*$/.test(filename) || filename.endsWith(".so");
}
return false;
}
function findLibrariesInDir(dir, pattern, options = {}, currentDepth = 0) {
const { maxDepth = 5, ignoreReadErrors = false } = options;
if (currentDepth >= maxDepth) return [];
const results = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (ignoreReadErrors) return [];
throw error;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...findLibrariesInDir(fullPath, pattern, options, currentDepth + 1));
} else if (matchesPattern(entry.name, pattern)) {
results.push(fullPath);
}
}
return results;
}
function copyLibraries(extractDir, destDir, pattern) {
const copied = [];
for (const libPath of findLibrariesInDir(extractDir, pattern)) {
const libName = path.basename(libPath);
const destPath = path.join(destDir, libName);
fs.copyFileSync(libPath, destPath);
setExecutable(destPath);
copied.push(libName);
}
return copied;
}
function cleanupFiles(binDir, prefix, keepPrefix) {
const keepPrefixes = Array.isArray(keepPrefix) ? keepPrefix : [keepPrefix];
// Never delete shared libraries; only platform binaries. The b9763 split ships
// llama-server-impl.dll, which shares the "llama-server" prefix and must survive.
const isLibrary = (f) => /\.(dll|dylib)$/i.test(f) || /\.so(\.\d+)*$/.test(f);
const files = fs.readdirSync(binDir).filter((f) => f.startsWith(prefix));
files.forEach((file) => {
if (!keepPrefixes.some((keep) => file.startsWith(keep)) && !isLibrary(file)) {
const filePath = path.join(binDir, file);
console.log(`Removing old binary: ${file}`);
fs.unlinkSync(filePath);
}
});
}
module.exports = {
copyLibraries,
downloadFile,
extractArchive,
extractZip,
fetchLatestRelease,
findBinaryInDir,
findLibrariesInDir,
matchesPattern,
parseArgs,
setExecutable,
cleanupFiles,
};