diff --git a/src/app/hack/[slug]/actions.ts b/src/app/hack/[slug]/actions.ts index c2e887a..988af3d 100644 --- a/src/app/hack/[slug]/actions.ts +++ b/src/app/hack/[slug]/actions.ts @@ -1132,6 +1132,8 @@ export interface PatchDownloadEventInput { pageOrigin?: string | null; correlationId?: string | null; sampleRate?: number | null; + resumeCount?: number | null; + receivedBytes?: number | null; } const PATCH_DOWNLOAD_STAGES = new Set(["signed_url", "fetch", "patch"]); @@ -1255,6 +1257,8 @@ export async function reportPatchDownloadEvent( page_origin: sanitizePageOrigin(input.pageOrigin), correlation_id: sanitizeCorrelationId(input.correlationId), sample_rate: sanitizeSampleRate(input.sampleRate), + resume_count: sanitizeNonNegativeInteger(input.resumeCount), + received_bytes: sanitizeNonNegativeInteger(input.receivedBytes), }); if (error) { diff --git a/src/components/Hack/HackActions.tsx b/src/components/Hack/HackActions.tsx index d6cb307..37ce560 100644 --- a/src/components/Hack/HackActions.tsx +++ b/src/components/Hack/HackActions.tsx @@ -16,12 +16,11 @@ import { import { getOrCreateDeviceId } from "@/utils/deviceId"; import { collectFetchDiagnostics, - collectResponseMetadata, - HTTPError, runConnectivityProbes, type FetchFailurePhase, type ResponseMetadata, } from "@/utils/patches/download-telemetry"; +import { downloadPatch, DownloadPatchError } from "@/utils/patches/download-patch"; import type { SelectablePatch } from "@/types/patcher"; import { applyPatch, patchFormatFromFilename, type PatchFormat } from "@/utils/patching"; import { createOutputSink, SaveCancelledError, type OutputSink } from "@/utils/patching/save"; @@ -86,7 +85,9 @@ const HackActions: React.FC = ({ const [romErrorModal, setRomErrorModal] = React.useState(null); const [isVerifyingRom, setIsVerifyingRom] = React.useState(false); const [selectedPatchId, setSelectedPatchId] = React.useState(patcherSelector.defaultPatchId); + const selectedPatchIdRef = React.useRef(selectedPatchId); const fetchSessionIdRef = React.useRef(undefined); + selectedPatchIdRef.current = selectedPatchId; function getFetchSessionId(): string | null { if (fetchSessionIdRef.current !== undefined) { @@ -128,6 +129,7 @@ const HackActions: React.FC = ({ function onVersionChange(nextPatchId: number) { if (nextPatchId === selectedPatchId) return; + selectedPatchIdRef.current = nextPatchId; setSelectedPatchId(nextPatchId); resetPatchSession(); } @@ -248,8 +250,10 @@ const HackActions: React.FC = ({ } async function onAgreeToTerms(): Promise<{ url: string; blob: Blob; format: PatchFormat } | null> { + const selectedPatchIdAtStart = selectedPatchId; const eventPatchId = selectedPatch?.id ?? patchId ?? null; const online = typeof navigator !== "undefined" ? navigator.onLine : null; + const correlationId = getFetchSessionId(); let signedUrl: string; let signedFormat: PatchFormat; @@ -273,12 +277,15 @@ const HackActions: React.FC = ({ errorMessage: result.error, online, pageOrigin: getPageOrigin(), - correlationId: getFetchSessionId(), + correlationId, probeSameOrigin: "skipped", probePatchHost: "skipped", }); return null; } + if (selectedPatchIdRef.current !== selectedPatchIdAtStart) { + return null; + } signedUrl = result.url; signedFormat = result.format; } catch (e: any) { @@ -293,13 +300,17 @@ const HackActions: React.FC = ({ errorMessage: e?.message ?? null, online, pageOrigin: getPageOrigin(), - correlationId: getFetchSessionId(), + correlationId, probeSameOrigin: "skipped", probePatchHost: "skipped", }); return null; } + if (selectedPatchIdRef.current !== selectedPatchIdAtStart) { + return null; + } + setPatchUrl(signedUrl); setPatchFormat(signedFormat); setTermsAgreed(true); @@ -314,50 +325,76 @@ const HackActions: React.FC = ({ }; const sessionFields = { pageOrigin: getPageOrigin(), - correlationId: getFetchSessionId(), + correlationId, }; try { - const res = await fetch(signedUrl); - failurePhase = "response"; - responseMeta = collectResponseMetadata(res); - if (!res.ok) throw new HTTPError(res.status); - failurePhase = "body"; - const blob = await res.blob(); - setPatchBlob(blob); + const download = await downloadPatch({ + url: signedUrl, + getNewSignedUrl: async () => { + const result = await getSignedPatchUrl( + hackSlug, + eventPatchId == null ? undefined : { patchId: eventPatchId }, + ); + if (!result.ok) throw new Error(result.error); + return result.url; + }, + }); + signedUrl = download.url; + failurePhase = download.failurePhase; + responseMeta = download.responseMetadata; + + const selectionIsCurrent = selectedPatchIdRef.current === selectedPatchIdAtStart; + if (selectionIsCurrent) { + setPatchUrl(download.url); + setPatchBlob(download.blob); + } const romReady = isRomReadyForPatch(); - if (!romReady) { + if (selectionIsCurrent && !romReady) { setStatus("idle"); } - if (Math.random() < 0.1) { + const shouldReportSuccess = download.resumeCount > 0 || Math.random() < 0.1; + if (shouldReportSuccess) { const elapsed = performance.now() - fetchStartedAt; - const diagnostics = collectFetchDiagnostics(signedUrl, elapsed); + const diagnostics = collectFetchDiagnostics(download.url, elapsed); deferReport({ patchId: eventPatchId, hackSlug, stage: "fetch", outcome: "success", online, - sampleRate: 0.1, + sampleRate: download.resumeCount > 0 ? 1 : 0.1, + resumeCount: download.resumeCount, + receivedBytes: download.receivedBytes, ...sessionFields, ...responseMeta, ...diagnostics, }); } - return { url: signedUrl, blob, format: signedFormat }; + if (!selectionIsCurrent) return null; + return { url: download.url, blob: download.blob, format: signedFormat }; } catch (e: any) { - setError(e?.name === "HTTPError" ? "Failed to fetch patch" : (e?.message || "Failed to fetch patch URL")); - setStatus("idle"); - setTermsAgreed(false); + const downloadError = e instanceof DownloadPatchError ? e : null; + const failureUrl = downloadError?.url ?? signedUrl; + const resumeCount = downloadError?.resumeCount ?? 0; + const receivedBytes = downloadError?.receivedBytes ?? 0; + failurePhase = downloadError?.failurePhase ?? failurePhase; + responseMeta = downloadError?.responseMetadata ?? responseMeta; + + if (selectedPatchIdRef.current === selectedPatchIdAtStart) { + setError(e?.name === "HTTPError" ? "Failed to fetch patch" : (e?.message || "Failed to fetch patch URL")); + setStatus("idle"); + setTermsAgreed(false); + } const elapsed = performance.now() - fetchStartedAt; setTimeout(async () => { try { - const probes = await runConnectivityProbes(signedUrl); - const diagnostics = collectFetchDiagnostics(signedUrl, elapsed); + const probes = await runConnectivityProbes(failureUrl); + const diagnostics = collectFetchDiagnostics(failureUrl, elapsed); await reportPatchDownloadEvent({ patchId: eventPatchId, hackSlug, @@ -367,6 +404,8 @@ const HackActions: React.FC = ({ errorName: e?.name ?? null, errorMessage: e?.message ?? null, online, + resumeCount, + receivedBytes, ...sessionFields, ...responseMeta, ...diagnostics, diff --git a/src/types/db.ts b/src/types/db.ts index e21ad9e..dcdb0ef 100644 --- a/src/types/db.ts +++ b/src/types/db.ts @@ -368,7 +368,9 @@ export type Database = { patch: number | null probe_patch_host: string | null probe_same_origin: string | null + received_bytes: number | null response_status: number | null + resume_count: number | null sample_rate: number | null stage: string timing_entry_present: boolean | null @@ -397,7 +399,9 @@ export type Database = { patch?: number | null probe_patch_host?: string | null probe_same_origin?: string | null + received_bytes?: number | null response_status?: number | null + resume_count?: number | null sample_rate?: number | null stage: string timing_entry_present?: boolean | null @@ -426,7 +430,9 @@ export type Database = { patch?: number | null probe_patch_host?: string | null probe_same_origin?: string | null + received_bytes?: number | null response_status?: number | null + resume_count?: number | null sample_rate?: number | null stage?: string timing_entry_present?: boolean | null diff --git a/src/utils/patches/download-patch.ts b/src/utils/patches/download-patch.ts new file mode 100644 index 0000000..83102ab --- /dev/null +++ b/src/utils/patches/download-patch.ts @@ -0,0 +1,224 @@ +import { + collectResponseMetadata, + HTTPError, + type FetchFailurePhase, + type ResponseMetadata, +} from "@/utils/patches/download-telemetry"; + +const MAX_ATTEMPTS = 5; + +type DownloadDetails = { + responseMetadata: ResponseMetadata; + failurePhase: FetchFailurePhase; + resumeCount: number; + receivedBytes: number; + url: string; +}; + +export type DownloadPatchResult = DownloadDetails & { + blob: Blob; +}; + +export class DownloadPatchError extends Error implements DownloadDetails { + readonly responseMetadata: ResponseMetadata; + readonly failurePhase: FetchFailurePhase; + readonly resumeCount: number; + readonly receivedBytes: number; + readonly url: string; + + constructor(error: unknown, details: DownloadDetails) { + const message = error instanceof Error ? error.message : "Failed to fetch patch"; + super(message, { cause: error }); + this.name = error instanceof Error ? error.name : "DownloadPatchError"; + this.responseMetadata = details.responseMetadata; + this.failurePhase = details.failurePhase; + this.resumeCount = details.resumeCount; + this.receivedBytes = details.receivedBytes; + this.url = details.url; + } +} + +export type DownloadPatchOptions = { + url: string; + getNewSignedUrl: () => Promise; +}; + +const EMPTY_RESPONSE_METADATA: ResponseMetadata = { + responseStatus: null, + contentLength: null, + contentEncoding: null, + contentType: null, +}; + +function parseContentRange(value: string | null): { start: number; total: number } | null { + if (value == null) return null; + const match = /^bytes (\d+)-\d+\/(\d+)$/.exec(value.trim()); + if (!match) return null; + + const start = Number(match[1]); + const total = Number(match[2]); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(total)) return null; + return { start, total }; +} + +export async function downloadPatch({ + url: initialUrl, + getNewSignedUrl, +}: DownloadPatchOptions): Promise { + let url = initialUrl; + let chunks: BlobPart[] = []; + let receivedBytes = 0; + let expectedLength: number | null = null; + let canResume = false; + let shouldResume = false; + let resumeCount = 0; + let responseMetadata = EMPTY_RESPONSE_METADATA; + let failurePhase: FetchFailurePhase = "request"; + let lastError: unknown = new Error("Failed to fetch patch"); + + const fail = (error: unknown): DownloadPatchError => + new DownloadPatchError(error, { + responseMetadata, + failurePhase, + resumeCount, + receivedBytes, + url, + }); + + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { + const rangeStart = shouldResume ? receivedBytes : null; + if (rangeStart != null) resumeCount += 1; + + let response: Response; + failurePhase = "request"; + try { + response = await fetch(url, rangeStart == null + ? undefined + : { headers: { Range: `bytes=${rangeStart}-` } }); + } catch (error) { + lastError = error; + chunks = []; + receivedBytes = 0; + expectedLength = null; + canResume = false; + shouldResume = false; + continue; + } + + failurePhase = "response"; + responseMetadata = collectResponseMetadata(response); + + if (response.status === 403) { + chunks = []; + receivedBytes = 0; + expectedLength = null; + canResume = false; + shouldResume = false; + try { + failurePhase = "request"; + url = await getNewSignedUrl(); + } catch (error) { + throw fail(error); + } + continue; + } + + if (!response.ok) { + throw fail(new HTTPError(response.status)); + } + + if (rangeStart != null && response.status === 206) { + const contentRange = parseContentRange(response.headers.get("content-range")); + if ( + contentRange == null + || contentRange.start !== rangeStart + || contentRange.total !== expectedLength + ) { + chunks = []; + receivedBytes = 0; + expectedLength = null; + canResume = false; + shouldResume = false; + lastError = new Error("Invalid Content-Range"); + continue; + } + } else if (rangeStart != null && response.status === 200) { + chunks = []; + receivedBytes = 0; + expectedLength = responseMetadata.contentLength; + canResume = expectedLength != null && responseMetadata.contentEncoding == null; + shouldResume = false; + } else if (rangeStart == null) { + expectedLength = responseMetadata.contentLength; + canResume = expectedLength != null && responseMetadata.contentEncoding == null; + } else { + throw fail(new HTTPError(response.status)); + } + + const bytesBeforeAttempt = receivedBytes; + let bodyFailed = false; + failurePhase = "body"; + + if (!response.body) { + try { + const blob = await response.blob(); + chunks.push(blob); + receivedBytes += blob.size; + } catch (error) { + throw fail(error); + } + } else { + const reader = response.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(new Uint8Array(value)); + receivedBytes += value.byteLength; + } + } catch (error) { + bodyFailed = true; + lastError = error; + if (expectedLength != null && receivedBytes === expectedLength) { + return { + blob: new Blob(chunks), + responseMetadata, + failurePhase, + resumeCount, + receivedBytes, + url, + }; + } + } + } + + if (!bodyFailed && (expectedLength == null || receivedBytes === expectedLength)) { + return { + blob: new Blob(chunks), + responseMetadata, + failurePhase, + resumeCount, + receivedBytes, + url, + }; + } + + if (receivedBytes === bytesBeforeAttempt) { + throw fail(lastError); + } + + if ( + canResume + && expectedLength != null + && receivedBytes > 0 + && receivedBytes < expectedLength + ) { + shouldResume = true; + continue; + } + + throw fail(lastError); + } + + throw fail(lastError); +} diff --git a/supabase/migrations/20260828044700_patch_download_event_resume.sql b/supabase/migrations/20260828044700_patch_download_event_resume.sql new file mode 100644 index 0000000..5bfe52f --- /dev/null +++ b/supabase/migrations/20260828044700_patch_download_event_resume.sql @@ -0,0 +1,7 @@ +-- Additive resume diagnostics for patch download telemetry. + +alter table public.patch_download_events + add column if not exists resume_count integer + check (resume_count is null or resume_count >= 0), + add column if not exists received_bytes bigint + check (received_bytes is null or received_bytes >= 0);