Files
routstrd/tests/utils/standalone-update.test.ts
redshift ec755bc84c fix(update): bound downloads with a two-tier deadline
`routstrd update` gave the whole release download the same 30 s budget as
the connection: `AbortSignal.timeout(FETCH_TIMEOUT_MS)` was attached to the
fetch, then the 38 MB archive was read with `arrayBuffer()` under that same
signal. Anything slower than ~1.3 MB/s failed with "The operation timed out."
— including the 540 KB/s / 70 s connection this was reproduced on.

Split the deadline in two:

- connect + response headers: 30 s, unchanged. The releases API and
  SHA256SUMS are a few KB, and a hang there is a real failure worth
  surfacing quickly.
- release archive body: 300 s. That is 38 MB / 300 s ≈ 127 KB/s ≈ 1 Mbit/s,
  about 4x headroom over the reproduction, while still bounding the silent
  failure (the command prints no progress) at five minutes.

`fetchOrThrow` now owns an AbortController and reschedules the deadline once
headers arrive, taking a `consume` callback so the body read is covered by
the transfer budget. Aborts report the phase and URL instead of a bare
"The operation timed out."

Also bound `getLatestNpmVersion`, which called `fetch` with no timeout at
all on the same `routstrd update` path; it already returns null on error, so
a stalled registry now degrades to "latest version unknown" instead of
hanging.

Adds regression tests: a body slower than the connect budget must still
install, and a body overrunning the transfer budget must fail with the
phase-aware message.
2026-10-03 11:12:38 +08:00

297 lines
9.1 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test";
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "fs";
import { tmpdir } from "os";
import { join } from "path";
import {
expectedChecksum,
getLatestStandaloneRelease,
installStandaloneRelease,
releaseArchiveName,
sha256,
} from "../../src/utils/standalone-update";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("releaseArchiveName", () => {
test("maps supported platforms and architectures", () => {
expect(releaseArchiveName("0.5.0", "linux", "x64")).toBe(
"routstrd-v0.5.0-linux-x64.tar.gz",
);
expect(releaseArchiveName("v0.5.0", "darwin", "arm64")).toBe(
"routstrd-v0.5.0-darwin-arm64.tar.gz",
);
});
test("rejects unsupported targets", () => {
expect(() => releaseArchiveName("0.5.0", "win32", "x64")).toThrow(
"not supported",
);
expect(() => releaseArchiveName("0.5.0", "linux", "riscv64")).toThrow(
"not supported",
);
});
});
describe("release metadata", () => {
test("selects the matching archive and checksum asset", async () => {
const fetchImpl = (async () =>
Response.json({
tag_name: "v0.5.0",
assets: [
{
name: "routstrd-v0.5.0-linux-x64.tar.gz",
browser_download_url: "https://example.com/routstrd.tar.gz",
},
{
name: "SHA256SUMS",
browser_download_url: "https://example.com/SHA256SUMS",
},
],
})) as typeof fetch;
const release = await getLatestStandaloneRelease("linux", "x64", fetchImpl);
expect(release.version).toBe("0.5.0");
expect(release.archive.name).toBe("routstrd-v0.5.0-linux-x64.tar.gz");
expect(release.checksums.name).toBe("SHA256SUMS");
});
test("rejects releases without the target artifact", async () => {
const fetchImpl = (async () =>
Response.json({ tag_name: "v0.5.0", assets: [] })) as typeof fetch;
expect(
getLatestStandaloneRelease("linux", "arm64", fetchImpl),
).rejects.toThrow("missing");
});
});
describe("checksum verification", () => {
test("finds a named checksum", () => {
const hash = "a".repeat(64);
expect(expectedChecksum(`${hash} routstrd.tar.gz\n`, "routstrd.tar.gz")).toBe(
hash,
);
});
test("hashes downloaded bytes", () => {
expect(sha256(new TextEncoder().encode("routstrd"))).toBe(
"328e1349fc24bab876a37f7eef3e7c95e9d06877867e21c6a9d5564b2cb3be05",
);
});
});
describe("installStandaloneRelease", () => {
test("validates and atomically replaces the current executable", async () => {
const dir = mkdtempSync(join(tmpdir(), "routstrd-updater-test-"));
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);
const checksum = sha256(archiveBytes);
const fetchImpl = (async (input: string | URL | Request) => {
const url = String(input);
if (url.endsWith("SHA256SUMS")) {
return new Response(`${checksum} ${releaseName}\n`);
}
return new Response(archiveBytes);
}) as typeof fetch;
const releaseName = "routstrd-v0.5.0-linux-x64.tar.gz";
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,
);
const version = Bun.spawnSync([current, "--version"]);
expect(version.exitCode).toBe(0);
expect(version.stdout.toString().trim()).toBe("0.5.0");
});
test("leaves the installed executable untouched on checksum failure", async () => {
const dir = mkdtempSync(join(tmpdir(), "routstrd-updater-test-"));
tempDirs.push(dir);
const current = join(dir, "installed-routstrd");
writeFileSync(current, "#!/bin/sh\necho 0.4.4\n");
chmodSync(current, 0o755);
const original = readFileSync(current, "utf8");
const fetchImpl = (async (input: string | URL | Request) => {
return String(input).endsWith("SHA256SUMS")
? new Response(`${"0".repeat(64)} routstrd-v0.5.0-linux-x64.tar.gz\n`)
: new Response("not the expected archive");
}) as typeof fetch;
await expect(
installStandaloneRelease(
{
version: "0.5.0",
archive: {
name: "routstrd-v0.5.0-linux-x64.tar.gz",
browser_download_url: "https://example/archive",
},
checksums: {
name: "SHA256SUMS",
browser_download_url: "https://example/SHA256SUMS",
},
},
current,
fetchImpl,
),
).rejects.toThrow("Checksum mismatch");
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<Uint8Array>({
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/);
});
});