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 { HttpTransport } from "../sync/transport/http.js";
|
||||||
import { TransportError } from "../sync/transport/types.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 { publicKeyFromDerBase64 } from "../sync/verifier/ed25519.js";
|
||||||
import type { TrustedKeySet } from "../sync/verifier/types.js";
|
import type { TrustedKeySet } from "../sync/verifier/types.js";
|
||||||
import { stageVerifiedPackage, activateStagedPackage } from "../sync/activation.js";
|
import { stageVerifiedPackage, activateStagedPackage } from "../sync/activation.js";
|
||||||
|
|
@ -44,6 +44,7 @@ export interface SyncRunDeps {
|
||||||
readonly appVersion?: string;
|
readonly appVersion?: string;
|
||||||
readonly supportedSchemaRange?: readonly number[];
|
readonly supportedSchemaRange?: readonly number[];
|
||||||
readonly onPhase?: (phase: "checking" | "downloading" | "activating") => void;
|
readonly onPhase?: (phase: "checking" | "downloading" | "activating") => void;
|
||||||
|
readonly onProgress?: (done: number, total: number | null, label: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSyncConfig(value: unknown): value is SyncConfig {
|
function isSyncConfig(value: unknown): value is SyncConfig {
|
||||||
|
|
@ -66,14 +67,21 @@ export function defaultConfigUrl(): string {
|
||||||
export async function loadSyncConfig(
|
export async function loadSyncConfig(
|
||||||
fetchImpl: typeof fetch,
|
fetchImpl: typeof fetch,
|
||||||
url: string = defaultConfigUrl(),
|
url: string = defaultConfigUrl(),
|
||||||
|
timeoutMs = 10_000,
|
||||||
): Promise<SyncConfig | null> {
|
): Promise<SyncConfig | null> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
}, timeoutMs);
|
||||||
try {
|
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;
|
if (!res.ok) return null;
|
||||||
const value: unknown = await res.json();
|
const value: unknown = await res.json();
|
||||||
return isSyncConfig(value) ? value : null;
|
return isSyncConfig(value) ? value : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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" };
|
if (trusted.size === 0) return { status: "not-configured", detail: "no trusted keys pinned" };
|
||||||
|
|
||||||
const transport = new HttpTransport(config.origin, { fetchImpl });
|
const transport = (() => {
|
||||||
const user = await openUserDB();
|
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 {
|
try {
|
||||||
deps.onPhase?.("checking");
|
deps.onPhase?.("checking");
|
||||||
const outcome = await pullCandidate(
|
const outcome = await pullCandidate(
|
||||||
|
|
@ -139,6 +164,13 @@ export async function runSync(deps: SyncRunDeps = {}): Promise<SyncOutcome> {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isQuarantined: (packageVersion) => isQuarantined(user, config.edition, packageVersion),
|
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 type { LatestPointer } from "../data/festival-package/types.js";
|
||||||
import { verifyPackage } from "./verifier/package.js";
|
import { verifyPackage } from "./verifier/package.js";
|
||||||
import type { VerifyDependencies, VerifyResult } from "./verifier/types.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 {
|
export interface CandidatePackage {
|
||||||
readonly pointer: LatestPointer;
|
readonly pointer: LatestPointer;
|
||||||
|
|
@ -16,6 +16,29 @@ export interface QuarantineSkipped {
|
||||||
|
|
||||||
export type PullOutcome = CandidatePackage | QuarantineSkipped | null;
|
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 {
|
function siblingUrl(manifestUrl: string, name: string): string {
|
||||||
if (/^[a-z][a-z\d+.-]*:/i.test(manifestUrl)) return new URL(name, manifestUrl).toString();
|
if (/^[a-z][a-z\d+.-]*:/i.test(manifestUrl)) return new URL(name, manifestUrl).toString();
|
||||||
return new URL(name, `https://transport.invalid${manifestUrl}`).pathname;
|
return new URL(name, `https://transport.invalid${manifestUrl}`).pathname;
|
||||||
|
|
@ -38,8 +61,31 @@ export async function pullCandidate(
|
||||||
readonly signal?: AbortSignal;
|
readonly signal?: AbortSignal;
|
||||||
readonly timeoutMs?: number;
|
readonly timeoutMs?: number;
|
||||||
readonly isQuarantined?: (packageVersion: number) => Promise<boolean>;
|
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> {
|
): 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;
|
if (!transport.isAvailable()) return null;
|
||||||
const pointer = await transport.fetchPointer(edition, options);
|
const pointer = await transport.fetchPointer(edition, options);
|
||||||
if (!pointer) return null;
|
if (!pointer) return null;
|
||||||
|
|
@ -47,10 +93,10 @@ export async function pullCandidate(
|
||||||
return { skipped: "quarantined", pointer };
|
return { skipped: "quarantined", pointer };
|
||||||
}
|
}
|
||||||
const manifestUrl = pointer.manifestUrl;
|
const manifestUrl = pointer.manifestUrl;
|
||||||
const manifestBytes = await transport.fetchBytes(manifestUrl, options);
|
const manifestBytes = await fetchWithRetry(manifestUrl, "manifest");
|
||||||
const signatureBytes = await transport.fetchBytes(
|
const signatureBytes = await fetchWithRetry(
|
||||||
siblingUrl(manifestUrl, "signature.json"),
|
siblingUrl(manifestUrl, "signature.json"),
|
||||||
options,
|
"signature",
|
||||||
);
|
);
|
||||||
let signature: unknown;
|
let signature: unknown;
|
||||||
try {
|
try {
|
||||||
|
|
@ -63,7 +109,7 @@ export async function pullCandidate(
|
||||||
manifestBytes,
|
manifestBytes,
|
||||||
signature,
|
signature,
|
||||||
files: {
|
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
|
// Pointer identity lets pre-manifest rejections (bad signature) still
|
||||||
// quarantine under the right edition/version — otherwise the no-loop
|
// 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 = {}) {
|
constructor(baseUrl: string | URL, options: HttpTransportOptions = {}) {
|
||||||
this.baseUrl = new URL(baseUrl);
|
this.baseUrl = new URL(baseUrl);
|
||||||
if (this.baseUrl.protocol !== "https:")
|
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;
|
const raw = options.fetchImpl ?? fetch;
|
||||||
// Native fetch rejects a non-Window receiver ("Illegal invocation") —
|
// Native fetch rejects a non-Window receiver ("Illegal invocation") —
|
||||||
// calling this.fetchImpl(url) would pass the transport as `this`. Bind it.
|
// calling this.fetchImpl(url) would pass the transport as `this`. Bind it.
|
||||||
|
|
@ -57,8 +60,11 @@ export class HttpTransport implements Transport {
|
||||||
}
|
}
|
||||||
|
|
||||||
isAvailable(): boolean {
|
isAvailable(): boolean {
|
||||||
if (typeof navigator === "undefined") return true;
|
// Deliberately NOT gated on navigator.onLine: an isolated festival LAN
|
||||||
return navigator.onLine;
|
// 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(
|
async fetchPointer(
|
||||||
|
|
|
||||||
|
|
@ -236,4 +236,16 @@ describe("app sync coordinator", () => {
|
||||||
});
|
});
|
||||||
expect(result.status).toBe("not-configured");
|
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();
|
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);
|
expect(transport.isAvailable()).toBe(true);
|
||||||
Object.defineProperty(navigator, "onLine", { configurable: true, value: false });
|
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();
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -164,6 +167,49 @@ describe("Stage 9 pull and verifier boundary", () => {
|
||||||
).toBeGreaterThan(2);
|
).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 () => {
|
it("does not retrieve protected files when the signature gate fails", async () => {
|
||||||
const keys = generateTestKeyPair();
|
const keys = generateTestKeyPair();
|
||||||
const built = buildPackage(makeValidInput(), { signWith: keys });
|
const built = buildPackage(makeValidInput(), { signWith: keys });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue