diff --git a/app/features/scanner/components/sendou-ingest.ts b/app/features/scanner/components/sendou-ingest.ts index 7069caee1..547a50b3c 100644 --- a/app/features/scanner/components/sendou-ingest.ts +++ b/app/features/scanner/components/sendou-ingest.ts @@ -32,6 +32,18 @@ const MAX_MATCHES_PER_REQUEST = 50; */ const UNLINKED_RETRY_DELAYS_MS = [30_000, 2 * 60_000, 5 * 60_000]; +/** A request sendou.ink hasn't answered by then fails, so a hung one can't hold up the sends queued behind it. */ +const INGEST_TIMEOUT_MS = 30_000; + +/** + * A "sending" status older than this was cut off (the tab closed mid-request) + * and counts as failed. Outlives any live request, which the timeout bounds. + */ +const STALE_SENDING_MS = 2 * INGEST_TIMEOUT_MS; + +/** Browsers refuse keepalive requests over 64 KiB; bigger ones go out without it. */ +const KEEPALIVE_MAX_BODY_BYTES = 64 * 1024; + export interface SendResult { sentMatches: number; failedMatches: number; @@ -117,13 +129,13 @@ export function matchContaining( /** * The single send status a match displays, folded from its source events: an * in-flight send wins, then failure, then success; within a state the most - * recent change is shown. + * recent change is shown. A send cut off mid-request shows as failed. */ export function aggregateSendStatus( sources: readonly ScanEvent[], ): SendStatus | undefined { const statuses = sources - .map((e) => e.send) + .map((e) => currentSendStatus(e.send)) .filter((status) => status !== undefined); for (const state of ["sending", "failed", "unlinked", "sent"] as const) { const ofState = statuses.filter((status) => status.state === state); @@ -136,9 +148,10 @@ export function aggregateSendStatus( /** Match selector: matches not yet sent (nor currently sending). */ export function unsentMatches(built: BuiltMatch): boolean { - return !built.sources.some( - (e) => e.send?.state === "sent" || e.send?.state === "sending", - ); + return !built.sources.some((e) => { + const state = currentSendStatus(e.send)?.state; + return state === "sent" || state === "sending"; + }); } /** Match selector: matches stored without a game to link to, whose next retry is due. */ @@ -164,6 +177,22 @@ export function unsentClosedMatches(built: BuiltMatch): boolean { ); } +function currentSendStatus( + status: SendStatus | undefined, +): SendStatus | undefined { + if ( + status?.state !== "sending" || + Date.now() - status.at < STALE_SENDING_MS + ) { + return status; + } + return { + state: "failed", + at: status.at, + error: "the upload was interrupted before sendou.ink answered", + }; +} + function ingestableBuilt( built: readonly BuiltMatch[], ): BuiltMatch[] { @@ -174,17 +203,30 @@ function ingestableBuilt( async function postIngestMatches( matches: ScannerMatch[], ): Promise { - const res = await fetch(INGEST_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ matches }), - }); - if (!res.ok) { - throw new Error( - res.status === 401 ? "not logged in to sendou.ink" : await errorText(res), - ); + const body = new TextEncoder().encode(JSON.stringify({ matches })); + try { + const res = await fetch(INGEST_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal: AbortSignal.timeout(INGEST_TIMEOUT_MS), + // lets the send finish when the tab closes right after Stop + keepalive: body.byteLength <= KEEPALIVE_MAX_BODY_BYTES, + }); + if (!res.ok) { + throw new Error( + res.status === 401 + ? "not logged in to sendou.ink" + : await errorText(res), + ); + } + return await res.json(); + } catch (err) { + if (err instanceof DOMException && err.name === "TimeoutError") { + throw new Error("sendou.ink didn't answer in time", { cause: err }); + } + throw err; } - return res.json(); } async function errorText(res: Response): Promise { diff --git a/app/features/scanner/tests/logic/send-status.test.ts b/app/features/scanner/tests/logic/send-status.test.ts new file mode 100644 index 000000000..f6ce1cd59 --- /dev/null +++ b/app/features/scanner/tests/logic/send-status.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + aggregateSendStatus, + unsentMatches, +} from "../../components/sendou-ingest"; +import type { ScanEvent } from "../../components/session-data"; +import type { BuiltMatch } from "../../core/match-builder"; +import type { ScannerMatch } from "../../core/scanner-match"; +import type { SendStatus } from "../../store/events"; + +const NOW = 1_800_000_000_000; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("aggregateSendStatus", () => { + test("a send still in flight shows as sending", () => { + const status = aggregateSendStatus([ + event({ state: "sending", at: NOW - 5_000 }), + ]); + + expect(status?.state).toBe("sending"); + }); + + test("a send cut off mid-request shows as failed", () => { + const status = aggregateSendStatus([ + event({ state: "sending", at: NOW - 5 * 60_000 }), + ]); + + expect(status?.state).toBe("failed"); + expect(status?.error).toBeDefined(); + }); +}); + +describe("unsentMatches", () => { + test.each([ + { why: "never attempted", send: undefined, expected: true }, + { why: "sent", send: { state: "sent", at: NOW }, expected: false }, + { + why: "sending", + send: { state: "sending", at: NOW - 5_000 }, + expected: false, + }, + { + why: "cut off mid-request", + send: { state: "sending", at: NOW - 5 * 60_000 }, + expected: true, + }, + ] satisfies Array<{ + why: string; + send: SendStatus | undefined; + expected: boolean; + }>)("$why", ({ send, expected }) => { + expect(unsentMatches(built([event(send)]))).toBe(expected); + }); +}); + +function event(send: SendStatus | undefined): ScanEvent { + return { type: "Scoreboard", t: 0, confidence: 1, data: null, send }; +} + +function built(sources: ScanEvent[]): BuiltMatch { + return { match: {} as ScannerMatch, sources }; +}