diff --git a/src/app/sync.ts b/src/app/sync.ts index 1917f6d..f094716 100644 --- a/src/app/sync.ts +++ b/src/app/sync.ts @@ -13,7 +13,7 @@ */ import { HttpTransport } from "../sync/transport/http.js"; import { TransportError } from "../sync/transport/types.js"; -import { pullCandidate } from "../sync/pull.js"; +import { pullCandidate, type PullProgress } from "../sync/pull.js"; import { publicKeyFromDerBase64 } from "../sync/verifier/ed25519.js"; import type { TrustedKeySet } from "../sync/verifier/types.js"; import { stageVerifiedPackage, activateStagedPackage } from "../sync/activation.js"; @@ -44,6 +44,7 @@ export interface SyncRunDeps { readonly appVersion?: string; readonly supportedSchemaRange?: readonly number[]; readonly onPhase?: (phase: "checking" | "downloading" | "activating") => void; + readonly onProgress?: (done: number, total: number | null, label: string) => void; } function isSyncConfig(value: unknown): value is SyncConfig { @@ -66,14 +67,21 @@ export function defaultConfigUrl(): string { export async function loadSyncConfig( fetchImpl: typeof fetch, url: string = defaultConfigUrl(), + timeoutMs = 10_000, ): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, timeoutMs); try { - const res = await fetchImpl(url, { cache: "no-store" }); + const res = await fetchImpl(url, { cache: "no-store", signal: controller.signal }); if (!res.ok) return null; const value: unknown = await res.json(); return isSyncConfig(value) ? value : null; } catch { return null; + } finally { + clearTimeout(timeout); } } @@ -124,8 +132,25 @@ export async function runSync(deps: SyncRunDeps = {}): Promise { } if (trusted.size === 0) return { status: "not-configured", detail: "no trusted keys pinned" }; - const transport = new HttpTransport(config.origin, { fetchImpl }); - const user = await openUserDB(); + const transport = (() => { + try { + return new HttpTransport(config.origin, { fetchImpl }); + } catch (error) { + return { error: describeError(error) } as const; + } + })(); + if ("error" in transport) { + return { + status: "error", + detail: `bad sync origin (${transport.error}) — the hub must serve HTTPS, e.g. node scripts/serve-demo.mjs`, + }; + } + let user; + try { + user = await openUserDB(); + } catch (error) { + return { status: "error", detail: `local storage unavailable (${describeError(error)})` }; + } try { deps.onPhase?.("checking"); const outcome = await pullCandidate( @@ -139,6 +164,13 @@ export async function runSync(deps: SyncRunDeps = {}): Promise { }, { isQuarantined: (packageVersion) => isQuarantined(user, config.edition, packageVersion), + ...(deps.onProgress + ? { + onProgress: (progress: PullProgress): void => { + deps.onProgress?.(progress.done, progress.total, progress.label); + }, + } + : {}), }, ); diff --git a/src/sync/pull.ts b/src/sync/pull.ts index c49c999..7dfabc1 100644 --- a/src/sync/pull.ts +++ b/src/sync/pull.ts @@ -1,7 +1,7 @@ import type { LatestPointer } from "../data/festival-package/types.js"; import { verifyPackage } from "./verifier/package.js"; import type { VerifyDependencies, VerifyResult } from "./verifier/types.js"; -import type { Transport } from "./transport/types.js"; +import { TransportError, type Transport } from "./transport/types.js"; export interface CandidatePackage { readonly pointer: LatestPointer; @@ -16,6 +16,29 @@ export interface QuarantineSkipped { export type PullOutcome = CandidatePackage | QuarantineSkipped | null; +export interface PullProgress { + /** Files fully fetched so far (manifest + signature + sections + assets). */ + readonly done: number; + /** Total files when known (unknown until the manifest + assets inventory load). */ + readonly total: number | null; + readonly label: string; +} + +function isRetryable(error: unknown): boolean { + if (!(error instanceof TransportError)) return false; + // 404/malformed/aborted mean "don't bother asking again". Timeouts, + // dropped connections and 5xx often clear on a festival box between waves. + return ( + error.code === "timeout" || error.code === "network_unavailable" || error.code === "http_error" + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + function siblingUrl(manifestUrl: string, name: string): string { if (/^[a-z][a-z\d+.-]*:/i.test(manifestUrl)) return new URL(name, manifestUrl).toString(); return new URL(name, `https://transport.invalid${manifestUrl}`).pathname; @@ -38,8 +61,31 @@ export async function pullCandidate( readonly signal?: AbortSignal; readonly timeoutMs?: number; readonly isQuarantined?: (packageVersion: number) => Promise; + /** Per-file retries on timeout/network/5xx (default 3). Total wait stays bounded. */ + readonly retries?: number; + /** File-granular progress so the UI never looks stuck on a busy hub. */ + readonly onProgress?: (progress: PullProgress) => void; } = {}, ): Promise { + const retries = options.retries ?? 3; + let done = 0; + const report = (total: number | null, label: string): void => { + options.onProgress?.({ done, total, label }); + }; + async function fetchWithRetry(path: string, label: string): Promise { + // oxlint-disable-next-line no-await-in-loop -- sequential retry with backoff is intentional + for (let attempt = 0; ; attempt++) { + try { + const bytes = await transport.fetchBytes(path, options); + done += 1; + report(null, label); + return bytes; + } catch (error) { + if (attempt >= retries || !isRetryable(error)) throw error; + await sleep(300 * (attempt + 1)); + } + } + } if (!transport.isAvailable()) return null; const pointer = await transport.fetchPointer(edition, options); if (!pointer) return null; @@ -47,10 +93,10 @@ export async function pullCandidate( return { skipped: "quarantined", pointer }; } const manifestUrl = pointer.manifestUrl; - const manifestBytes = await transport.fetchBytes(manifestUrl, options); - const signatureBytes = await transport.fetchBytes( + const manifestBytes = await fetchWithRetry(manifestUrl, "manifest"); + const signatureBytes = await fetchWithRetry( siblingUrl(manifestUrl, "signature.json"), - options, + "signature", ); let signature: unknown; try { @@ -63,7 +109,7 @@ export async function pullCandidate( manifestBytes, signature, files: { - getFile: (path) => transport.fetchBytes(siblingUrl(manifestUrl, path), options), + getFile: (path) => fetchWithRetry(siblingUrl(manifestUrl, path), path), }, // Pointer identity lets pre-manifest rejections (bad signature) still // quarantine under the right edition/version — otherwise the no-loop diff --git a/src/sync/transport/http.ts b/src/sync/transport/http.ts index b5abc91..b23ab8b 100644 --- a/src/sync/transport/http.ts +++ b/src/sync/transport/http.ts @@ -48,7 +48,10 @@ export class HttpTransport implements Transport { constructor(baseUrl: string | URL, options: HttpTransportOptions = {}) { this.baseUrl = new URL(baseUrl); if (this.baseUrl.protocol !== "https:") - throw new TransportError("malformed_response", "HTTP transport requires HTTPS"); + throw new TransportError( + "malformed_response", + `HTTP transport requires HTTPS (got ${this.baseUrl.protocol}//${this.baseUrl.host}) — serve the hub with node scripts/serve-demo.mjs, not plain HTTP`, + ); const raw = options.fetchImpl ?? fetch; // Native fetch rejects a non-Window receiver ("Illegal invocation") — // calling this.fetchImpl(url) would pass the transport as `this`. Bind it. @@ -57,8 +60,11 @@ export class HttpTransport implements Transport { } isAvailable(): boolean { - if (typeof navigator === "undefined") return true; - return navigator.onLine; + // Deliberately NOT gated on navigator.onLine: an isolated festival LAN + // box has no upstream internet, so browsers report "offline" while the + // origin is fully reachable. A truly dead network fails fast at fetch + // time (network_unavailable) instead of hanging the UI. + return true; } async fetchPointer( diff --git a/tests/unit/app-sync.test.ts b/tests/unit/app-sync.test.ts index 7e6ea2b..b9a0c5f 100644 --- a/tests/unit/app-sync.test.ts +++ b/tests/unit/app-sync.test.ts @@ -236,4 +236,16 @@ describe("app sync coordinator", () => { }); expect(result.status).toBe("not-configured"); }); + + it("returns an error outcome (never throws/hangs) for a plain-HTTP origin", async () => { + const origin = buildOrigin(); + const httpConfig = { ...config(origin), origin: "http://10.144.0.221:8080/origin" }; + const result = await runSync({ + fetchConfig: async () => httpConfig, + fetchImpl: fakeFetch(origin), + appVersion: "1.0.0", + }); + expect(result.status).toBe("error"); + expect(result.status === "error" && result.detail).toContain("HTTPS"); + }); }); diff --git a/tests/unit/transport.test.ts b/tests/unit/transport.test.ts index cd237bc..ed2c3fb 100644 --- a/tests/unit/transport.test.ts +++ b/tests/unit/transport.test.ts @@ -48,10 +48,13 @@ describe("Stage 9 HttpTransport", () => { vi.restoreAllMocks(); }); - it("reports availability from the browser online hint without making a request", () => { + it("reports available even when the browser thinks it is offline (isolated LAN hub)", () => { expect(transport.isAvailable()).toBe(true); Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); - expect(transport.isAvailable()).toBe(false); + // A festival box has no upstream internet: onLine is false while the + // origin is reachable. Availability must not gate the attempt — a truly + // dead network fails fast at fetch time instead. + expect(transport.isAvailable()).toBe(true); expect(fetchImpl).not.toHaveBeenCalled(); }); @@ -164,6 +167,49 @@ describe("Stage 9 pull and verifier boundary", () => { ).toBeGreaterThan(2); }); + it("retries a transient network failure mid-download and reports progress", async () => { + const keys = generateTestKeyPair(); + const built = buildPackage(makeValidInput(), { signWith: keys }); + if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed"); + const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest)); + const files = new Map(); + files.set("/editions/lumen-2026/packages/1/manifest.json", manifestBytes); + files.set( + "/editions/lumen-2026/packages/1/signature.json", + new TextEncoder().encode(JSON.stringify(built.pkg.signature)), + ); + for (const [name, file] of built.pkg.files) + files.set(`/editions/lumen-2026/packages/1/${name}`, file.canonicalBytes); + for (const asset of built.pkg.assets) + files.set(`/editions/lumen-2026/packages/1/${asset.file}`, asset.bytesContent); + let emergencyFailures = 1; + const seen: string[] = []; + const fetchImpl = vi.fn((input: RequestInfo | URL) => { + const url = inputUrl(input); + if (url.endsWith("/latest.json")) return Promise.resolve(response(pointer())); + if (url.endsWith("/emergency.json") && emergencyFailures > 0) { + emergencyFailures -= 1; + return Promise.reject(new TypeError("wifi hiccup")); + } + const body = files.get(new URL(url).pathname); + return Promise.resolve(body ? response(body) : response("missing", 404)); + }); + const result = await pullCandidate( + new HttpTransport("https://festival.example/", { fetchImpl }), + "lumen-2026", + { + trustedKeys: new Map([[keys.fingerprint, publicKeyFromDerBase64(keys.publicKeyDerBase64)]]), + appVersion: "1.0.0", + supportedSchemaRange: [1], + }, + { onProgress: (progress) => seen.push(progress.label) }, + ); + if (!result || "skipped" in result) throw new Error("expected candidate"); + expect(result.result.ok).toBe(true); + expect(emergencyFailures).toBe(0); + expect(seen).toContain("emergency.json"); + }); + it("does not retrieve protected files when the signature gate fails", async () => { const keys = generateTestKeyPair(); const built = buildPackage(makeValidInput(), { signWith: keys });