sync robustness: per-file retry with backoff, file-granular progress, bounded config fetch timeout, honest error details (bad origin / storage unavailable), isAvailable no longer gated on navigator.onLine (isolated LAN reports offline while origin is reachable)

This commit is contained in:
Lumen Stage1 2026-10-06 10:12:04 -05:00
commit a0db9f7862
5 changed files with 156 additions and 14 deletions

View file

@ -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<SyncConfig | null> {
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<SyncOutcome> {
}
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<SyncOutcome> {
},
{
isQuarantined: (packageVersion) => isQuarantined(user, config.edition, packageVersion),
...(deps.onProgress
? {
onProgress: (progress: PullProgress): void => {
deps.onProgress?.(progress.done, progress.total, progress.label);
},
}
: {}),
},
);

View file

@ -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<void> {
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<boolean>;
/** 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<PullOutcome> {
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<Uint8Array> {
// 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

View file

@ -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(

View file

@ -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");
});
});

View file

@ -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<string, Uint8Array>();
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 });