mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-10-20 14:54:36 -05:00
Resume patch downloads that die mid-body instead of starting over (#82)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<HackActionsProps> = ({
|
||||
const [romErrorModal, setRomErrorModal] = React.useState<BaseRomErrorModalState | null>(null);
|
||||
const [isVerifyingRom, setIsVerifyingRom] = React.useState(false);
|
||||
const [selectedPatchId, setSelectedPatchId] = React.useState<number | null>(patcherSelector.defaultPatchId);
|
||||
const selectedPatchIdRef = React.useRef(selectedPatchId);
|
||||
const fetchSessionIdRef = React.useRef<string | null | undefined>(undefined);
|
||||
selectedPatchIdRef.current = selectedPatchId;
|
||||
|
||||
function getFetchSessionId(): string | null {
|
||||
if (fetchSessionIdRef.current !== undefined) {
|
||||
@@ -128,6 +129,7 @@ const HackActions: React.FC<HackActionsProps> = ({
|
||||
|
||||
function onVersionChange(nextPatchId: number) {
|
||||
if (nextPatchId === selectedPatchId) return;
|
||||
selectedPatchIdRef.current = nextPatchId;
|
||||
setSelectedPatchId(nextPatchId);
|
||||
resetPatchSession();
|
||||
}
|
||||
@@ -248,8 +250,10 @@ const HackActions: React.FC<HackActionsProps> = ({
|
||||
}
|
||||
|
||||
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<HackActionsProps> = ({
|
||||
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<HackActionsProps> = ({
|
||||
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<HackActionsProps> = ({
|
||||
};
|
||||
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<HackActionsProps> = ({
|
||||
errorName: e?.name ?? null,
|
||||
errorMessage: e?.message ?? null,
|
||||
online,
|
||||
resumeCount,
|
||||
receivedBytes,
|
||||
...sessionFields,
|
||||
...responseMeta,
|
||||
...diagnostics,
|
||||
|
||||
@@ -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
|
||||
|
||||
224
src/utils/patches/download-patch.ts
Normal file
224
src/utils/patches/download-patch.ts
Normal file
@@ -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<string>;
|
||||
};
|
||||
|
||||
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<DownloadPatchResult> {
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user