diff --git a/src/utils/standalone-update.ts b/src/utils/standalone-update.ts index bd4b494..25f8d2e 100644 --- a/src/utils/standalone-update.ts +++ b/src/utils/standalone-update.ts @@ -13,11 +13,19 @@ import { tmpdir } from "os"; import { basename, join } from "path"; const RELEASES_API = "https://api.github.com/repos/Routstr/routstrd/releases/latest"; -const FETCH_TIMEOUT_MS = 30_000; +/** Time allowed to connect and receive response headers. */ +const CONNECT_TIMEOUT_MS = 30_000; +/** Time allowed to download a release archive once headers have arrived. */ +const TRANSFER_TIMEOUT_MS = 300_000; const PROCESS_TIMEOUT_MS = 30_000; const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024; const MAX_CHECKSUM_BYTES = 1024 * 1024; +export type UpdateTimeouts = { + connectMs?: number; + transferMs?: number; +}; + type ReleaseAsset = { name: string; browser_download_url: string; @@ -52,27 +60,74 @@ export function releaseArchiveName( return `routstrd-v${normalizeVersion(version)}-${platform}-${arch}.tar.gz`; } -async function fetchOrThrow( +type FetchOptions = { + maxBytes?: number; + connectTimeoutMs?: number; + transferTimeoutMs?: number; +}; + +/** + * Fetch `url` and hand the response to `consume` while it is still covered by a + * deadline. The connect+header phase gets the short `CONNECT_TIMEOUT_MS`; once + * headers are in, the deadline is relaxed to the (much longer) + * `transferTimeoutMs` so that a slow-but-progressing download is not killed for + * failing to fit a 38 MB archive into the connect budget. + */ +async function fetchOrThrow( url: string, fetchImpl: typeof fetch, - maxBytes = MAX_CHECKSUM_BYTES, -): Promise { - const response = await fetchImpl(url, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "routstrd", - }, - redirect: "follow", - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - }); - if (!response.ok) { - throw new Error(`Download failed (${response.status}) for ${url}`); + { + maxBytes = MAX_CHECKSUM_BYTES, + connectTimeoutMs = CONNECT_TIMEOUT_MS, + transferTimeoutMs = CONNECT_TIMEOUT_MS, + }: FetchOptions, + consume: (response: Response) => Promise, +): Promise { + const controller = new AbortController(); + let deadlineHit = false; + let phase: "connect" | "transfer" = "connect"; + const startDeadline = (ms: number) => + setTimeout(() => { + deadlineHit = true; + controller.abort(); + }, ms); + let timer = startDeadline(connectTimeoutMs); + + try { + const response = await fetchImpl(url, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "routstrd", + }, + redirect: "follow", + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Download failed (${response.status}) for ${url}`); + } + const contentLength = Number(response.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + throw new Error(`Download is too large (${contentLength} bytes) for ${url}`); + } + + // Headers are in: the body gets its own, larger budget. + phase = "transfer"; + clearTimeout(timer); + timer = startDeadline(transferTimeoutMs); + + return await consume(response); + } catch (error) { + if (deadlineHit) { + const seconds = Math.round( + (phase === "connect" ? connectTimeoutMs : transferTimeoutMs) / 1000, + ); + const what = phase === "connect" ? "Connecting to" : "Downloading from"; + throw new Error(`${what} ${url} timed out after ${seconds}s.`, { cause: error }); + } + throw error; + } finally { + clearTimeout(timer); } - const contentLength = Number(response.headers.get("content-length")); - if (Number.isFinite(contentLength) && contentLength > maxBytes) { - throw new Error(`Download is too large (${contentLength} bytes) for ${url}`); - } - return response; } async function waitForExit( @@ -100,8 +155,12 @@ export async function getLatestStandaloneRelease( arch: string = process.arch, fetchImpl: typeof fetch = fetch, ): Promise { - const response = await fetchOrThrow(RELEASES_API, fetchImpl); - const release = (await response.json()) as GithubRelease; + const release = await fetchOrThrow( + RELEASES_API, + fetchImpl, + {}, + async (response) => (await response.json()) as GithubRelease, + ); const version = normalizeVersion(release.tag_name || ""); if (!/^\d+\.\d+\.\d+/.test(version)) { throw new Error("The latest GitHub Release has an invalid version tag."); @@ -151,28 +210,43 @@ export async function installStandaloneRelease( release: StandaloneRelease, executablePath: string = process.execPath, fetchImpl: typeof fetch = fetch, + timeouts: UpdateTimeouts = {}, ): Promise { + const connectMs = timeouts.connectMs ?? CONNECT_TIMEOUT_MS; + const transferMs = timeouts.transferMs ?? TRANSFER_TIMEOUT_MS; + /** SHA256SUMS is a few hundred bytes, so it gets the short budget, not the archive's. */ + const checksumMs = connectMs; const tempDir = mkdtempSync(join(tmpdir(), "routstrd-update-")); const archivePath = join(tempDir, release.archive.name); const stagedPath = `${executablePath}.update-${randomBytes(12).toString("hex")}`; try { - const [archiveResponse, checksumsResponse] = await Promise.all([ + const [archiveBytes, checksums] = await Promise.all([ fetchOrThrow( release.archive.browser_download_url, fetchImpl, - MAX_ARCHIVE_BYTES, + { maxBytes: MAX_ARCHIVE_BYTES, connectTimeoutMs: connectMs, transferTimeoutMs: transferMs }, + async (response) => { + const bytes = await response.arrayBuffer(); + if (bytes.byteLength > MAX_ARCHIVE_BYTES) { + throw new Error(`Download is too large (${bytes.byteLength} bytes).`); + } + return bytes; + }, + ), + fetchOrThrow( + release.checksums.browser_download_url, + fetchImpl, + { connectTimeoutMs: connectMs, transferTimeoutMs: checksumMs }, + async (response) => { + const contents = await response.text(); + if (Buffer.byteLength(contents) > MAX_CHECKSUM_BYTES) { + throw new Error("SHA256SUMS is too large."); + } + return contents; + }, ), - fetchOrThrow(release.checksums.browser_download_url, fetchImpl), ]); - const archiveBytes = await archiveResponse.arrayBuffer(); - if (archiveBytes.byteLength > MAX_ARCHIVE_BYTES) { - throw new Error(`Download is too large (${archiveBytes.byteLength} bytes).`); - } - const checksums = await checksumsResponse.text(); - if (Buffer.byteLength(checksums) > MAX_CHECKSUM_BYTES) { - throw new Error("SHA256SUMS is too large."); - } const expected = expectedChecksum(checksums, release.archive.name); const actual = sha256(archiveBytes); if (actual !== expected) { diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts index 5ae0387..2e4460e 100644 --- a/src/utils/update-checker.ts +++ b/src/utils/update-checker.ts @@ -3,6 +3,8 @@ import { VERSION } from "../version"; import { getLatestStandaloneRelease } from "./standalone-update"; const NPM_REGISTRY = "https://registry.npmjs.org"; +/** Bounds the update check so a stalled registry cannot hang `routstrd update`. */ +const NPM_TIMEOUT_MS = 30_000; /** Packages that `routstrd update` manages. */ export const UPDATE_PACKAGES = [ @@ -20,6 +22,7 @@ export async function getLatestNpmVersion( try { const response = await fetch( `${NPM_REGISTRY}/${encodeURIComponent(packageName)}/latest`, + { signal: AbortSignal.timeout(NPM_TIMEOUT_MS) }, ); if (!response.ok) return null; const data = (await response.json()) as { version?: string }; diff --git a/tests/utils/standalone-update.test.ts b/tests/utils/standalone-update.test.ts index 31063f7..39ae1c0 100644 --- a/tests/utils/standalone-update.test.ts +++ b/tests/utils/standalone-update.test.ts @@ -179,3 +179,118 @@ describe("installStandaloneRelease", () => { expect(readFileSync(current, "utf8")).toBe(original); }); }); + +describe("download deadlines", () => { + async function makeFixture() { + const dir = mkdtempSync(join(tmpdir(), "routstrd-updater-timeout-")); + tempDirs.push(dir); + const current = join(dir, "installed-routstrd"); + const archiveRoot = join(dir, "archive"); + const candidate = join(archiveRoot, "routstrd"); + const archivePath = join(dir, "routstrd-v0.5.0-linux-x64.tar.gz"); + mkdirSync(archiveRoot); + writeFileSync(current, "#!/bin/sh\necho 0.4.4\n"); + writeFileSync(candidate, "#!/bin/sh\necho 0.5.0\n"); + chmodSync(current, 0o755); + chmodSync(candidate, 0o755); + const tar = Bun.spawnSync([ + "tar", + "-C", + archiveRoot, + "-czf", + archivePath, + "routstrd", + ]); + expect(tar.exitCode).toBe(0); + const archiveBytes = readFileSync(archivePath); + return { + current, + archiveBytes, + releaseName: "routstrd-v0.5.0-linux-x64.tar.gz", + checksum: sha256(archiveBytes), + }; + } + + /** Headers land immediately; the body arrives after `delayMs`, or errors on abort. */ + function delayedResponse( + bytes: Uint8Array, + delayMs: number, + signal: AbortSignal | null | undefined, + ): Response { + const stream = new ReadableStream({ + start(controller) { + const timer = setTimeout(() => { + controller.enqueue(bytes); + controller.close(); + }, delayMs); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + controller.error(new DOMException("Aborted", "AbortError")); + }, + { once: true }, + ); + }, + }); + return new Response(stream); + } + + function slowArchiveFetch( + archiveBytes: Uint8Array, + checksum: string, + releaseName: string, + bodyDelayMs: number, + ): typeof fetch { + return (async (input: string | URL | Request, init?: RequestInit) => + String(input).endsWith("SHA256SUMS") + ? new Response(`${checksum} ${releaseName}\n`) + : delayedResponse(archiveBytes, bodyDelayMs, init?.signal)) as typeof fetch; + } + + test("does not kill a slow archive on the connect budget", async () => { + const { current, archiveBytes, releaseName, checksum } = await makeFixture(); + // Body takes 80ms; the connect budget is 20ms. Regression guard for the flat + // 30s deadline that failed real 70s downloads. + const fetchImpl = slowArchiveFetch(archiveBytes, checksum, releaseName, 80); + + await installStandaloneRelease( + { + version: "0.5.0", + archive: { name: releaseName, browser_download_url: "https://example/archive" }, + checksums: { name: "SHA256SUMS", browser_download_url: "https://example/SHA256SUMS" }, + }, + current, + fetchImpl, + { connectMs: 20, transferMs: 2_000 }, + ); + + const version = Bun.spawnSync([current, "--version"]); + expect(version.exitCode).toBe(0); + expect(version.stdout.toString().trim()).toBe("0.5.0"); + }); + + test("gives up when the transfer deadline is exceeded", async () => { + const { current, archiveBytes, releaseName, checksum } = await makeFixture(); + const fetchImpl = slowArchiveFetch(archiveBytes, checksum, releaseName, 300); + + await expect( + installStandaloneRelease( + { + version: "0.5.0", + archive: { + name: releaseName, + browser_download_url: "https://example/archive", + }, + checksums: { + name: "SHA256SUMS", + browser_download_url: "https://example/SHA256SUMS", + }, + }, + current, + fetchImpl, + { connectMs: 1_000, transferMs: 50 }, + ), + ).rejects.toThrow(/Downloading from .* timed out after/); + }); +});