diff --git a/engine-sources.lock.json b/engine-sources.lock.json index b6f3f59..1a76245 100644 --- a/engine-sources.lock.json +++ b/engine-sources.lock.json @@ -13,9 +13,9 @@ "sha256": "171efab55ac6b9881fd53ee4c20f8bf3bb1340ffc618483746909014db12216a" }, "ffmpeg": { - "version": "8.1.2-40-g852b0552f0", - "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-14-13-16/ffmpeg-n8.1.2-40-g852b0552f0-win64-gpl-8.1.zip", - "sha256": "4dc80a665fd8a3481acae3f7836807334396a607581f7123b35a71f2ebaacb5d" + "version": "8.1.2-44-g7c533d0f86", + "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-18-15-03/ffmpeg-n8.1.2-44-g7c533d0f86-win64-gpl-8.1.zip", + "sha256": "66e3797adad33063ae3f55c7eacb9f1bff604322a4e50225039626230fd0c0d1" }, "aria2c": { "version": "1.37.0", @@ -35,9 +35,9 @@ "sha256": "8b010a3b1a4a0188a67cdb8a7a27348b2a501af78aec7fc74f2ace167368d530" }, "ffmpeg": { - "version": "8.1.2-40-g852b0552f0", - "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-14-13-16/ffmpeg-n8.1.2-40-g852b0552f0-linux64-gpl-8.1.tar.xz", - "sha256": "ee02f959c14a19888cff231525d3f543524c05241dfac821071cf7ddfe2c466b" + "version": "8.1.2-44-g7c533d0f86", + "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-18-15-03/ffmpeg-n8.1.2-44-g7c533d0f86-linux64-gpl-8.1.tar.xz", + "sha256": "03ccc8a1cb534b97c2bc43f322ddb1b7c23bd325abb7e4c31aa37f4b4c0e648f" }, "aria2c": { "version": "1.37.0", diff --git a/package-lock.json b/package-lock.json index e46e387..3cafc38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "i18next": "^26.3.6", - "lucide-react": "^1.31.0", + "lucide-react": "^1.32.0", "react": "^19.2.8", "react-dom": "^19.2.8", "react-i18next": "^17.0.11", @@ -41,7 +41,7 @@ "tailwindcss": "^4.3.3", "typescript": "^7.0.2", "vite": "^8.2.1", - "vitest": "^4.1.10" + "vitest": "^4.1.11" }, "engines": { "node": ">=22" @@ -1442,16 +1442,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1460,13 +1460,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1487,9 +1487,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -1500,13 +1500,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -1514,14 +1514,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1530,9 +1530,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -1540,13 +1540,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2118,9 +2118,9 @@ } }, "node_modules/lucide-react": { - "version": "1.31.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", - "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.32.0.tgz", + "integrity": "sha512-txX56hMFnRxPi1f9/nH69YN8uvAO6a7Y1KSWKjCDAtdD9+soEgmWuCt6iRm1pkxUZo2+YntSdsE1L6bIuKoY8Q==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -2829,19 +2829,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -2869,12 +2869,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index b459f3b..90b5797 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "i18next": "^26.3.6", - "lucide-react": "^1.31.0", + "lucide-react": "^1.32.0", "react": "^19.2.8", "react-dom": "^19.2.8", "react-i18next": "^17.0.11", @@ -76,6 +76,6 @@ "tailwindcss": "^4.3.3", "typescript": "^7.0.2", "vite": "^8.2.1", - "vitest": "^4.1.10" + "vitest": "^4.1.11" } } diff --git a/scripts/engine-download.js b/scripts/engine-download.js new file mode 100644 index 0000000..975953c --- /dev/null +++ b/scripts/engine-download.js @@ -0,0 +1,185 @@ +import fs from 'node:fs'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import { sha256 } from './engine-payload-integrity.js'; + +const DEFAULT_ATTEMPTS = 3; +const DEFAULT_IDLE_TIMEOUT_MS = 120_000; +const DEFAULT_RETRY_DELAYS_MS = [2_000, 5_000]; +const FILE_RESET_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000]; + +function parseContentRange(value) { + const match = /^bytes (\d+)-(\d+)\/(\d+|\*)$/.exec(value || ''); + if (!match) return undefined; + const start = Number(match[1]); + const end = Number(match[2]); + const total = match[3] === '*' ? undefined : Number(match[3]); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end < start) { + return undefined; + } + if (total !== undefined && (!Number.isSafeInteger(total) || end >= total)) { + return undefined; + } + return { start, end, total }; +} + +function responseLength(response) { + const value = response.headers.get('content-length'); + if (!value || !/^\d+$/.test(value)) return undefined; + const length = Number(value); + return Number.isSafeInteger(length) ? length : undefined; +} + +function createDownloadTimeout(idleTimeoutMs) { + const controller = new AbortController(); + let timer; + const refresh = () => { + clearTimeout(timer); + timer = setTimeout(() => { + controller.abort(new Error(`Download idle for ${idleTimeoutMs}ms`)); + }, idleTimeoutMs); + }; + const dispose = () => clearTimeout(timer); + refresh(); + return { signal: controller.signal, refresh, dispose }; +} + +function archiveSize(archive) { + try { + return fs.statSync(archive).size; + } catch (error) { + if (error?.code === 'ENOENT') return 0; + throw error; + } +} + +function checksumMismatchError(name, expected, actual) { + const error = new Error(`Archive checksum mismatch for ${name}. Expected ${expected}, got ${actual}`); + error.code = 'ARCHIVE_CHECKSUM_MISMATCH'; + return error; +} + +function sleep(milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} + +async function resetArchive(archive) { + for (let attempt = 0; ; attempt += 1) { + try { + fs.rmSync(archive, { force: true }); + return; + } catch (error) { + const retryable = process.platform === 'win32' + && ['EACCES', 'EBUSY', 'EPERM'].includes(error?.code); + if (!retryable || attempt >= FILE_RESET_RETRY_DELAYS_MS.length) throw error; + await sleep(FILE_RESET_RETRY_DELAYS_MS[attempt]); + } + } +} + +/** + * Download and checksum an engine archive, resuming an interrupted response + * when the provider honors HTTP range requests. A provider that ignores the + * range is handled safely by replacing the partial file instead of appending + * a second full archive to it. + */ +export async function downloadEngineArchive({ + name, + url, + archive, + expectedSha256, + attempts = DEFAULT_ATTEMPTS, + idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS, + retryDelaysMs = DEFAULT_RETRY_DELAYS_MS, +}) { + let lastError; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const partialSize = archiveSize(archive); + if (partialSize > 0 && sha256(archive) === expectedSha256) return archive; + const downloadTimeout = createDownloadTimeout(idleTimeoutMs); + let resetForRetry = false; + + try { + const response = await fetch(url, { + headers: partialSize > 0 ? { Range: `bytes=${partialSize}-` } : undefined, + redirect: 'follow', + signal: downloadTimeout.signal, + }); + + if (response.status === 416 && partialSize > 0) { + await response.body?.cancel(); + resetForRetry = true; + throw new Error(`Retained partial archive range is not satisfiable for ${name}`); + } + + if (!response.ok || !response.body) { + await response.body?.cancel(); + throw new Error(`Failed to download ${name}: HTTP ${response.status}`); + } + + const contentRange = response.status === 206 + ? parseContentRange(response.headers.get('content-range')) + : undefined; + if (response.status === 206 && (!contentRange || contentRange.start !== partialSize)) { + await response.body.cancel(); + throw new Error(`Invalid Content-Range while downloading ${name}`); + } + + const append = response.status === 206 && partialSize > 0; + const expectedResponseLength = responseLength(response); + if (!append && partialSize > 0) { + // The provider ignored Range and returned the complete archive. + await resetArchive(archive); + } + + await pipeline( + Readable.fromWeb(response.body), + new Transform({ + transform(chunk, encoding, callback) { + downloadTimeout.refresh(); + callback(null, chunk, encoding); + }, + }), + fs.createWriteStream(archive, { flags: append ? 'a' : 'w' }), + { signal: downloadTimeout.signal }, + ); + + const finalSize = archiveSize(archive); + const expectedFinalSize = contentRange?.total + ?? (expectedResponseLength === undefined + ? undefined + : (append ? partialSize + expectedResponseLength : expectedResponseLength)); + if (expectedFinalSize !== undefined && finalSize !== expectedFinalSize) { + throw new Error( + `Incomplete archive for ${name}: expected ${expectedFinalSize} bytes, got ${finalSize}`, + ); + } + + const actual = sha256(archive); + if (actual === expectedSha256) return archive; + + resetForRetry = true; + throw checksumMismatchError(name, expectedSha256, actual); + } catch (error) { + lastError = error; + if (resetForRetry || error?.code === 'ARCHIVE_CHECKSUM_MISMATCH') { + await resetArchive(archive); + } + if (attempt === attempts) { + throw new Error( + `Failed to download ${name} after ${attempts} attempts: ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error }, + ); + } + await new Promise(resolve => setTimeout(resolve, retryDelaysMs[attempt - 1] ?? 0)); + } finally { + downloadTimeout.dispose(); + } + } + + throw lastError; +} diff --git a/scripts/engine-download.node-test.js b/scripts/engine-download.node-test.js new file mode 100644 index 0000000..da02092 --- /dev/null +++ b/scripts/engine-download.node-test.js @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { downloadEngineArchive } from './engine-download.js'; + +async function withMockFetch(mockFetch, callback) { + const originalFetch = globalThis.fetch; + globalThis.fetch = mockFetch; + try { + return await callback(); + } finally { + globalThis.fetch = originalFetch; + } +} + +function makeBody(chunks, failure) { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(Buffer.from(chunk)); + if (failure) controller.error(failure); + else controller.close(); + }, + }); +} + +function makeArchivePath() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-download-')); + return { + directory, + archive: path.join(directory, 'engine.zip'), + }; +} + +function digest(value) { + return createHash('sha256').update(value).digest('hex'); +} + +test('resumes an interrupted archive response from the retained partial file', async () => { + const { directory, archive } = makeArchivePath(); + const full = Buffer.from('complete archive payload'); + const prefix = full.subarray(0, 8); + const middle = full.subarray(8, 15); + const suffix = full.subarray(15); + fs.writeFileSync(archive, Buffer.concat([prefix, middle])); + const ranges = []; + let calls = 0; + + try { + await withMockFetch(async (_url, options) => { + calls += 1; + ranges.push(options.headers?.Range); + if (calls === 1) { + return new Response(makeBody([], new Error('connection reset')), { + status: 206, + headers: { + 'Content-Length': String(suffix.length), + 'Content-Range': `bytes ${prefix.length + middle.length}-${full.length - 1}/${full.length}`, + }, + }); + } + return new Response(makeBody([suffix]), { + status: 206, + headers: { + 'Content-Length': String(suffix.length), + 'Content-Range': `bytes ${prefix.length + middle.length}-${full.length - 1}/${full.length}`, + }, + }); + }, async () => { + await downloadEngineArchive({ + name: 'test', + url: 'https://example.test/engine.zip', + archive, + expectedSha256: digest(full), + attempts: 2, + retryDelaysMs: [0], + }); + }); + + assert.deepEqual( + ranges, + [`bytes=${prefix.length + middle.length}-`, `bytes=${prefix.length + middle.length}-`], + ); + assert.deepEqual(fs.readFileSync(archive), full); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test('replaces a partial archive when the provider ignores the Range request', async () => { + const { directory, archive } = makeArchivePath(); + const full = Buffer.from('complete archive after range fallback'); + fs.writeFileSync(archive, Buffer.from('stale partial bytes')); + let requestedRange; + + try { + await withMockFetch(async (_url, options) => { + requestedRange = options.headers?.Range; + return new Response(full, { + status: 200, + headers: { 'Content-Length': String(full.length) }, + }); + }, async () => { + await downloadEngineArchive({ + name: 'test', + url: 'https://example.test/engine.zip', + archive, + expectedSha256: digest(full), + attempts: 1, + }); + }); + + assert.equal(requestedRange, 'bytes=19-'); + assert.deepEqual(fs.readFileSync(archive), full); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test('accepts a complete retained archive without issuing an unsatisfiable range', async () => { + const { directory, archive } = makeArchivePath(); + const full = Buffer.from('complete archive retained after a late connection reset'); + fs.writeFileSync(archive, full); + let calls = 0; + + try { + await withMockFetch(async () => { + calls += 1; + throw new Error('fetch should not be called for a complete retained archive'); + }, async () => { + await downloadEngineArchive({ + name: 'test', + url: 'https://example.test/engine.zip', + archive, + expectedSha256: digest(full), + attempts: 1, + }); + }); + + assert.equal(calls, 0); + assert.deepEqual(fs.readFileSync(archive), full); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test('restarts from zero after an unsatisfiable retained range', async () => { + const { directory, archive } = makeArchivePath(); + const full = Buffer.from('complete archive after a stale range response'); + fs.writeFileSync(archive, Buffer.from('stale partial bytes')); + const ranges = []; + let calls = 0; + + try { + await withMockFetch(async (_url, options) => { + calls += 1; + ranges.push(options.headers?.Range); + if (calls === 1) return new Response(null, { status: 416 }); + return new Response(full, { + status: 200, + headers: { 'Content-Length': String(full.length) }, + }); + }, async () => { + await downloadEngineArchive({ + name: 'test', + url: 'https://example.test/engine.zip', + archive, + expectedSha256: digest(full), + attempts: 2, + retryDelaysMs: [0], + }); + }); + + assert.deepEqual(ranges, ['bytes=19-', undefined]); + assert.deepEqual(fs.readFileSync(archive), full); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/scripts/provision-engines.js b/scripts/provision-engines.js index 8fa7c4a..b89d644 100644 --- a/scripts/provision-engines.js +++ b/scripts/provision-engines.js @@ -1,11 +1,10 @@ #!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; -import { Readable, Transform } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { collectRegularFiles, sha256 } from './engine-payload-integrity.js'; +import { downloadEngineArchive } from './engine-download.js'; import { promoteDirectory, recoverInterruptedPromotion, @@ -47,44 +46,6 @@ const payloadDestination = path.join(temporary, 'payload'); fs.mkdirSync(payloadDestination, { recursive: true }); const isWindows = target.includes('windows'); const executableSuffix = isWindows ? '.exe' : ''; -const DOWNLOAD_ATTEMPTS = 3; -const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000; -const DOWNLOAD_RETRY_DELAYS_MS = [2_000, 5_000]; -const FILE_LOCK_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000]; - -function sleep(milliseconds) { - return new Promise(resolve => setTimeout(resolve, milliseconds)); -} - -async function removeFileWithRetry(file) { - for (let attempt = 0; ; attempt += 1) { - try { - fs.rmSync(file, { force: true }); - return; - } catch (error) { - const retryable = process.platform === 'win32' - && ['EACCES', 'EBUSY', 'EPERM'].includes(error?.code); - if (!retryable || attempt >= FILE_LOCK_RETRY_DELAYS_MS.length) { - throw error; - } - await sleep(FILE_LOCK_RETRY_DELAYS_MS[attempt]); - } - } -} - -function createDownloadTimeout() { - const controller = new AbortController(); - let timer; - const refresh = () => { - clearTimeout(timer); - timer = setTimeout(() => { - controller.abort(new Error(`Download idle for ${DOWNLOAD_IDLE_TIMEOUT_MS}ms`)); - }, DOWNLOAD_IDLE_TIMEOUT_MS); - }; - const dispose = () => clearTimeout(timer); - refresh(); - return { signal: controller.signal, refresh, dispose }; -} async function download(name, source) { const sourcePath = new URL(source.url).pathname; @@ -92,56 +53,12 @@ async function download(name, source) { temporary, `${name}${sourcePath.endsWith('.tar.xz') ? '.tar.xz' : '.zip'}` ); - - let lastError; - for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) { - const downloadTimeout = createDownloadTimeout(); - try { - const response = await fetch(source.url, { - redirect: 'follow', - signal: downloadTimeout.signal, - }); - if (!response.ok || !response.body) { - throw new Error(`Failed to download ${name}: HTTP ${response.status}`); - } - - await pipeline( - Readable.fromWeb(response.body), - new Transform({ - transform(chunk, encoding, callback) { - downloadTimeout.refresh(); - callback(null, chunk, encoding); - }, - }), - fs.createWriteStream(archive), - { signal: downloadTimeout.signal } - ); - break; - } catch (error) { - lastError = error; - await removeFileWithRetry(archive); - if (attempt === DOWNLOAD_ATTEMPTS) { - throw new Error( - `Failed to download ${name} after ${DOWNLOAD_ATTEMPTS} attempts: ${ - error instanceof Error ? error.message : String(error) - }`, - { cause: error } - ); - } - await sleep(DOWNLOAD_RETRY_DELAYS_MS[attempt - 1]); - } finally { - downloadTimeout.dispose(); - } - } - - if (lastError && !fs.existsSync(archive)) { - throw lastError; - } - - const actual = sha256(archive); - if (actual !== source.sha256) { - throw new Error(`Archive checksum mismatch for ${name}. Expected ${source.sha256}, got ${actual}`); - } + await downloadEngineArchive({ + name, + url: source.url, + archive, + expectedSha256: source.sha256, + }); const extracted = path.join(temporary, `${name}-extracted`); fs.mkdirSync(extracted); if (archive.endsWith('.zip') && process.platform !== 'win32') { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e3f74e3..09aa467 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1311,9 +1311,9 @@ dependencies = [ [[package]] name = "error-code" -version = "3.3.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] name = "event-listener" @@ -7122,9 +7122,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" dependencies = [ "proc-macro2", "quote", @@ -7154,9 +7154,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", @@ -7169,9 +7169,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -7182,9 +7182,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "4.0.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote",