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:
parent
bb40c96f98
commit
a0db9f7862
5 changed files with 156 additions and 14 deletions
|
|
@ -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);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue