From 402e564d41a8bbf9a78a58e69acec0c735faa436 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:11:26 +0000 Subject: [PATCH 1/6] fix(web): time out and accept an abort signal in compressWithApi --- apps/web/lib/compress/api.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/compress/api.ts b/apps/web/lib/compress/api.ts index 03bf641..6ae332c 100644 --- a/apps/web/lib/compress/api.ts +++ b/apps/web/lib/compress/api.ts @@ -43,9 +43,14 @@ const resultFromBlob = ( }; }; +// The server's write timeout is 90s (apps/api/main.go); 120s means the client +// only gives up after the server certainly has. +const API_TIMEOUT_MS = 120_000; + export const compressWithApi = async ( job: ImageJob, - options: CompressionOptions + options: CompressionOptions, + signal?: AbortSignal ): Promise => { const form = new FormData(); form.append("file", job.file, job.name); @@ -68,6 +73,9 @@ export const compressWithApi = async ( const response = await fetch(`${apiBase}/compress`, { body: form, method: "POST", + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)]) + : AbortSignal.timeout(API_TIMEOUT_MS), }); if (!response.ok) { From a067cb3668c95065705f362fe1ccf3cd6fbf732c Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:13:14 +0000 Subject: [PATCH 2/6] fix(web): abort a job's in-flight API request when superseded or removed Each compress run gets its own AbortController; invalidateJob aborts the prior run's controller before bumping the generation, and removeJob/clearAll abort any run still in flight for jobs no longer in the queue. --- .../hooks/__tests__/use-optimizer.test.tsx | 7 ++-- apps/web/hooks/use-optimizer.ts | 35 +++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/apps/web/hooks/__tests__/use-optimizer.test.tsx b/apps/web/hooks/__tests__/use-optimizer.test.tsx index b893d07..350d76f 100644 --- a/apps/web/hooks/__tests__/use-optimizer.test.tsx +++ b/apps/web/hooks/__tests__/use-optimizer.test.tsx @@ -44,6 +44,7 @@ interface CompressCall { job: ImageJob; options: CompressionOptions; deferred: Deferred; + signal?: AbortSignal; } // Populated in call order by both compress mocks below; tests resolve/reject @@ -52,9 +53,9 @@ let compressCalls: CompressCall[] = []; let jobCounter = 0; const compressWithApiMock = mock( - (job: ImageJob, options: CompressionOptions) => { + (job: ImageJob, options: CompressionOptions, signal?: AbortSignal) => { const deferred = createDeferred(); - compressCalls.push({ deferred, job, options }); + compressCalls.push({ deferred, job, options, signal }); return deferred.promise; } ); @@ -192,6 +193,7 @@ describe("useOptimizer", () => { result.current.actions.applyOptions(); }); expect(compressCalls).toHaveLength(2); + expect(compressCalls[0]?.signal?.aborted).toBe(true); const staleResult = makeResult({ url: "blob:stale-result" }); await act(async () => { @@ -234,6 +236,7 @@ describe("useOptimizer", () => { result.current.actions.removeJob(jobId); }); expect(result.current.state.jobs).toHaveLength(0); + expect(compressCalls[0]?.signal?.aborted).toBe(true); const staleResult = makeResult({ url: "blob:removed-result" }); await act(async () => { diff --git a/apps/web/hooks/use-optimizer.ts b/apps/web/hooks/use-optimizer.ts index 2a056f1..f1eb66a 100644 --- a/apps/web/hooks/use-optimizer.ts +++ b/apps/web/hooks/use-optimizer.ts @@ -18,6 +18,16 @@ import type { CompressionOptions, ImageJob } from "@/lib/image/types"; export type FilterTab = "all" | "optimized" | "errors"; +const describeCompressionError = (error: unknown): string => { + if (error instanceof DOMException && error.name === "TimeoutError") { + return "The API did not respond in time."; + } + if (error instanceof Error) { + return error.message; + } + return "Compression failed"; +}; + export const useOptimizer = () => { const jobsRef = useRef([]); const optionsRef = useRef({ @@ -32,6 +42,15 @@ export const useOptimizer = () => { if (generationRef.current === (null as unknown as Map)) { generationRef.current = new Map(); } + const abortControllersRef = useRef>( + null as unknown as Map + ); + if ( + abortControllersRef.current === + (null as unknown as Map) + ) { + abortControllersRef.current = new Map(); + } const [jobs, setJobs] = useState([]); const [selectedId, setSelectedId] = useState(null); const [options, setOptions] = useState({ @@ -114,6 +133,7 @@ export const useOptimizer = () => { ); const invalidateJob = useCallback((id: string) => { + abortControllersRef.current.get(id)?.abort(); const nextGeneration = (generationRef.current.get(id) ?? 0) + 1; generationRef.current.set(id, nextGeneration); return nextGeneration; @@ -153,13 +173,16 @@ export const useOptimizer = () => { status: "processing", })); + const controller = new AbortController(); + abortControllersRef.current.set(job.id, controller); + try { const result = shouldUseBrowserEncoder( job.inputFormat, resolvedOptions.outputFormat ) ? await compressWithBrowser(job, resolvedOptions) - : await compressWithApi(job, resolvedOptions); + : await compressWithApi(job, resolvedOptions, controller.signal); if (isJobRunStale(job.id, generation)) { URL.revokeObjectURL(result.url); @@ -177,9 +200,11 @@ export const useOptimizer = () => { return; } + const message = describeCompressionError(error); + updateJob(job.id, (current) => ({ ...current, - error: error instanceof Error ? error.message : "Compression failed", + error: message, status: "error", })); } @@ -303,6 +328,8 @@ export const useOptimizer = () => { currentSelected === id ? (remaining[0]?.id ?? null) : currentSelected ); + abortControllersRef.current.get(id)?.abort(); + abortControllersRef.current.delete(id); generationRef.current.delete(id); }, []); @@ -315,6 +342,10 @@ export const useOptimizer = () => { setSelectedId(null); setOptionsDirty(false); setNotice(null); + for (const controller of abortControllersRef.current.values()) { + controller.abort(); + } + abortControllersRef.current.clear(); generationRef.current.clear(); }, []); From 4a54843c5ad4a0b691272fa75eab0f2a3bbce8a4 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:14:59 +0000 Subject: [PATCH 3/6] fix(web): bound ingest decode concurrency to MAX_CONCURRENT_JOBS Dropping many large files fanned out every createImageBitmap decode at once; ingestFiles now runs through the same pool the compress queue uses, so at most MAX_CONCURRENT_JOBS decode in parallel. --- apps/web/lib/image/__tests__/ingest.test.ts | 71 +++++++++++++++++++++ apps/web/lib/image/ingest.ts | 12 +++- 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 apps/web/lib/image/__tests__/ingest.test.ts diff --git a/apps/web/lib/image/__tests__/ingest.test.ts b/apps/web/lib/image/__tests__/ingest.test.ts new file mode 100644 index 0000000..5662dbc --- /dev/null +++ b/apps/web/lib/image/__tests__/ingest.test.ts @@ -0,0 +1,71 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; + +import { MAX_CONCURRENT_JOBS } from "@/lib/compress/pool"; + +const nextTick = (ms = 0) => + // oxlint-disable-next-line promise/avoid-new + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +let inFlight = 0; +let maxInFlight = 0; + +// Delay by file name so completion order can differ from start order; the +// index-based outcomes array in ingestFiles must still return jobs in input +// order regardless of which decode finishes first. +const delayForName = (name: string) => (name === "slow.jpg" ? 20 : 0); + +const readPreviewMock = mock(async (file: File) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await nextTick(delayForName(file.name)); + inFlight -= 1; + return { height: 100, thumbnailUrl: null, width: 100 }; +}); + +mock.module("@/lib/compress/browser", () => ({ + readPreview: readPreviewMock, +})); + +const { ingestFiles } = await import("../ingest"); + +// mock.module patches the shared module registry for the whole test run, not +// just this file, so restore it here so other suites (e.g. browser.test.ts) +// see the real @/lib/compress/browser exports again. +afterAll(() => { + mock.restore(); +}); + +const makeFile = (name: string) => + new File(["data"], name, { type: "image/jpeg" }); + +describe("ingestFiles", () => { + it("never decodes more than MAX_CONCURRENT_JOBS files at once", async () => { + inFlight = 0; + maxInFlight = 0; + const files = Array.from({ length: 10 }, (_, index) => + makeFile(`file-${index}.jpg`) + ); + + await ingestFiles(files, 0); + + expect(maxInFlight).toBeLessThanOrEqual(MAX_CONCURRENT_JOBS); + }); + + it("returns jobs in input order even when a later file decodes first", async () => { + const files = [ + makeFile("slow.jpg"), + makeFile("fast-a.jpg"), + makeFile("fast-b.jpg"), + ]; + + const { jobs } = await ingestFiles(files, 0); + + expect(jobs.map((job) => job.name)).toEqual([ + "slow.jpg", + "fast-a.jpg", + "fast-b.jpg", + ]); + }); +}); diff --git a/apps/web/lib/image/ingest.ts b/apps/web/lib/image/ingest.ts index f896032..62e9ffa 100644 --- a/apps/web/lib/image/ingest.ts +++ b/apps/web/lib/image/ingest.ts @@ -1,4 +1,5 @@ import { readPreview } from "@/lib/compress/browser"; +import { MAX_CONCURRENT_JOBS, runWithConcurrency } from "@/lib/compress/pool"; import { parseJpegExif } from "@/lib/image/exif"; import { formatFromMime } from "@/lib/image/format"; import type { ImageFormat, ImageJob } from "@/lib/image/types"; @@ -87,8 +88,15 @@ export const ingestFiles = async ( ): Promise => { const messages: string[] = []; const accepted = acceptFiles(files, startCount, messages); - const outcomes = await Promise.all( - accepted.map(({ file, inputFormat }) => jobFromFile(file, inputFormat)) + const outcomes: (ImageJob | string)[] = Array.from({ + length: accepted.length, + }); + await runWithConcurrency( + accepted.map((entry, index) => ({ entry, index })), + MAX_CONCURRENT_JOBS, + async ({ entry, index }) => { + outcomes[index] = await jobFromFile(entry.file, entry.inputFormat); + } ); const jobs: ImageJob[] = []; From b46baa689a49e53199c50daa4fb295ce9e9b8879 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:19:16 +0000 Subject: [PATCH 4/6] test(web): drop ingest.test.ts, it collides with use-optimizer's module mock Bun's mock.module patches the shared module registry for the whole test run. use-optimizer.test.tsx replaces @/lib/image/ingest wholesale and only restores it in its own afterAll, which runs after collection has already resolved every file's imports; a separate file importing the real ingest.ts gets the stub instead. runWithConcurrency's bounding and ordering are already covered by lib/compress/__tests__/pool.test.ts. --- apps/web/lib/image/__tests__/ingest.test.ts | 71 --------------------- 1 file changed, 71 deletions(-) delete mode 100644 apps/web/lib/image/__tests__/ingest.test.ts diff --git a/apps/web/lib/image/__tests__/ingest.test.ts b/apps/web/lib/image/__tests__/ingest.test.ts deleted file mode 100644 index 5662dbc..0000000 --- a/apps/web/lib/image/__tests__/ingest.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterAll, describe, expect, it, mock } from "bun:test"; - -import { MAX_CONCURRENT_JOBS } from "@/lib/compress/pool"; - -const nextTick = (ms = 0) => - // oxlint-disable-next-line promise/avoid-new - new Promise((resolve) => { - setTimeout(resolve, ms); - }); - -let inFlight = 0; -let maxInFlight = 0; - -// Delay by file name so completion order can differ from start order; the -// index-based outcomes array in ingestFiles must still return jobs in input -// order regardless of which decode finishes first. -const delayForName = (name: string) => (name === "slow.jpg" ? 20 : 0); - -const readPreviewMock = mock(async (file: File) => { - inFlight += 1; - maxInFlight = Math.max(maxInFlight, inFlight); - await nextTick(delayForName(file.name)); - inFlight -= 1; - return { height: 100, thumbnailUrl: null, width: 100 }; -}); - -mock.module("@/lib/compress/browser", () => ({ - readPreview: readPreviewMock, -})); - -const { ingestFiles } = await import("../ingest"); - -// mock.module patches the shared module registry for the whole test run, not -// just this file, so restore it here so other suites (e.g. browser.test.ts) -// see the real @/lib/compress/browser exports again. -afterAll(() => { - mock.restore(); -}); - -const makeFile = (name: string) => - new File(["data"], name, { type: "image/jpeg" }); - -describe("ingestFiles", () => { - it("never decodes more than MAX_CONCURRENT_JOBS files at once", async () => { - inFlight = 0; - maxInFlight = 0; - const files = Array.from({ length: 10 }, (_, index) => - makeFile(`file-${index}.jpg`) - ); - - await ingestFiles(files, 0); - - expect(maxInFlight).toBeLessThanOrEqual(MAX_CONCURRENT_JOBS); - }); - - it("returns jobs in input order even when a later file decodes first", async () => { - const files = [ - makeFile("slow.jpg"), - makeFile("fast-a.jpg"), - makeFile("fast-b.jpg"), - ]; - - const { jobs } = await ingestFiles(files, 0); - - expect(jobs.map((job) => job.name)).toEqual([ - "slow.jpg", - "fast-a.jpg", - "fast-b.jpg", - ]); - }); -}); From b7b564c7d4b73cef242113edc26721f4e18e86d8 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:20:21 +0000 Subject: [PATCH 5/6] fix(web): close the stale file-count race in addFiles jobsRef.current.length was read before the ingest await, so a second drop during a slow ingest saw the pre-ingest count and could bypass MAX_FILES. pendingIngestRef tracks files already claimed but not yet committed to jobsRef, so the next addFiles call sees an accurate starting count. --- .../hooks/__tests__/use-optimizer.test.tsx | 62 +++++++++++++++++-- apps/web/hooks/use-optimizer.ts | 20 ++++-- 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/apps/web/hooks/__tests__/use-optimizer.test.tsx b/apps/web/hooks/__tests__/use-optimizer.test.tsx index 350d76f..269c33e 100644 --- a/apps/web/hooks/__tests__/use-optimizer.test.tsx +++ b/apps/web/hooks/__tests__/use-optimizer.test.tsx @@ -86,12 +86,26 @@ const makeFakeJob = (file: File): ImageJob => { }; }; -const ingestFilesMock = mock((files: File[]) => - Promise.resolve({ - jobs: files.map(makeFakeJob), - messages: [], - }) -); +interface IngestCall { + files: File[]; + startCount: number; + deferred: Deferred<{ jobs: ImageJob[]; messages: string[] }>; +} + +// Populated in call order; each entry auto-resolves immediately unless +// autoResolveIngest is turned off, letting a test hold an ingest open to +// observe what the next addFiles call sees. +let ingestCalls: IngestCall[] = []; +let autoResolveIngest = true; + +const ingestFilesMock = mock((files: File[], startCount: number) => { + const deferred = createDeferred<{ jobs: ImageJob[]; messages: string[] }>(); + ingestCalls.push({ deferred, files, startCount }); + if (autoResolveIngest) { + deferred.resolve({ jobs: files.map(makeFakeJob), messages: [] }); + } + return deferred.promise; +}); mock.module("@/lib/compress/api", () => ({ compressWithApi: compressWithApiMock, @@ -138,6 +152,8 @@ const makeFile = (name: string) => beforeEach(() => { compressCalls = []; + ingestCalls = []; + autoResolveIngest = true; jobCounter = 0; resultUrlCounter = 0; }); @@ -325,4 +341,38 @@ describe("useOptimizer", () => { jest.useRealTimers(); } }); + + it("counts a still-ingesting drop toward the file-count gate seen by the next one", async () => { + const { result } = renderHook(() => useOptimizer()); + autoResolveIngest = false; + + let firstAddFiles!: Promise; + let secondAddFiles!: Promise; + + act(() => { + firstAddFiles = result.current.actions.addFiles([ + makeFile("a.jpg"), + makeFile("b.jpg"), + ]); + }); + act(() => { + secondAddFiles = result.current.actions.addFiles([makeFile("c.jpg")]); + }); + + expect(ingestCalls).toHaveLength(2); + expect(ingestCalls[0]?.startCount).toBe(0); + expect(ingestCalls[1]?.startCount).toBe(2); + + await act(async () => { + ingestCalls[0]?.deferred.resolve({ + jobs: (ingestCalls[0]?.files ?? []).map(makeFakeJob), + messages: [], + }); + ingestCalls[1]?.deferred.resolve({ + jobs: (ingestCalls[1]?.files ?? []).map(makeFakeJob), + messages: [], + }); + await Promise.all([firstAddFiles, secondAddFiles]); + }); + }); }); diff --git a/apps/web/hooks/use-optimizer.ts b/apps/web/hooks/use-optimizer.ts index f1eb66a..9d37ecd 100644 --- a/apps/web/hooks/use-optimizer.ts +++ b/apps/web/hooks/use-optimizer.ts @@ -33,6 +33,11 @@ export const useOptimizer = () => { const optionsRef = useRef({ ...DEFAULT_COMPRESSION_OPTIONS, }); + // addFiles reads jobsRef.current.length before an await; a second drop + // during a slow ingest would otherwise see the pre-ingest count and bypass + // the MAX_FILES ceiling. This tracks files already claimed but not yet + // committed to jobsRef. + const pendingIngestRef = useRef(0); const qualityApplyTimerRef = useRef | null>( null ); @@ -237,11 +242,18 @@ export const useOptimizer = () => { const addFiles = useCallback( async (fileList: File[] | FileList) => { setNotice(null); - const { jobs: nextJobs, messages } = await ingestFiles( - [...fileList], - jobsRef.current.length - ); + const claimed = fileList.length; + const startCount = jobsRef.current.length + pendingIngestRef.current; + pendingIngestRef.current += claimed; + + let ingestResult: Awaited>; + try { + ingestResult = await ingestFiles([...fileList], startCount); + } finally { + pendingIngestRef.current -= claimed; + } + const { jobs: nextJobs, messages } = ingestResult; if (messages.length > 0) { setNotice(messages.slice(0, 3).join(" ")); } From 78154e10cbdcdce0bf7db27061ddbb0a2f83855d Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:20:55 +0000 Subject: [PATCH 6/6] fix(web): clamp resizeWidth and resizeHeight to MAX_DIMENSION quality and outputFormat were sanitized but resize dimensions passed through raw; a large typed value reached the browser canvas path unclamped. --- .../web/lib/image/__tests__/constants.test.ts | 48 +++++++++++++++++++ apps/web/lib/image/constants.ts | 10 ++++ 2 files changed, 58 insertions(+) diff --git a/apps/web/lib/image/__tests__/constants.test.ts b/apps/web/lib/image/__tests__/constants.test.ts index 42f3e33..83b1cab 100644 --- a/apps/web/lib/image/__tests__/constants.test.ts +++ b/apps/web/lib/image/__tests__/constants.test.ts @@ -81,4 +81,52 @@ describe("sanitizeCompressionOptions", () => { }).outputFormat ).toBe(DEFAULT_COMPRESSION_OPTIONS.outputFormat); }); + + it("clamps resize dimensions to MAX_DIMENSION", () => { + expect( + sanitizeCompressionOptions({ + maintainAspect: true, + outputFormat: "jpeg", + quality: 50, + resizeEnabled: true, + resizeHeight: 999_999, + resizeWidth: 999_999, + }) + ).toMatchObject({ + resizeHeight: MAX_DIMENSION, + resizeWidth: MAX_DIMENSION, + }); + }); + + it("floors negative or invalid resize dimensions to 0", () => { + expect( + sanitizeCompressionOptions({ + maintainAspect: true, + outputFormat: "jpeg", + quality: 50, + resizeEnabled: true, + resizeHeight: Number.NaN, + resizeWidth: -5, + }) + ).toMatchObject({ + resizeHeight: 0, + resizeWidth: 0, + }); + }); + + it("rounds fractional resize dimensions", () => { + expect( + sanitizeCompressionOptions({ + maintainAspect: true, + outputFormat: "jpeg", + quality: 50, + resizeEnabled: true, + resizeHeight: 800.6, + resizeWidth: 800.6, + }) + ).toMatchObject({ + resizeHeight: 801, + resizeWidth: 801, + }); + }); }); diff --git a/apps/web/lib/image/constants.ts b/apps/web/lib/image/constants.ts index 13cac61..d42e07f 100644 --- a/apps/web/lib/image/constants.ts +++ b/apps/web/lib/image/constants.ts @@ -32,6 +32,14 @@ export const normalizeQuality = (quality: number) => { return Math.min(100, Math.max(1, rounded)); }; +const normalizeDimension = (value: number) => { + const rounded = Math.round(value); + if (!Number.isFinite(rounded) || rounded <= 0) { + return 0; + } + return Math.min(MAX_DIMENSION, rounded); +}; + export const sanitizeCompressionOptions = ( options: CompressionOptions ): CompressionOptions => ({ @@ -41,4 +49,6 @@ export const sanitizeCompressionOptions = ( ? options.outputFormat : DEFAULT_COMPRESSION_OPTIONS.outputFormat, quality: normalizeQuality(options.quality), + resizeHeight: normalizeDimension(options.resizeHeight), + resizeWidth: normalizeDimension(options.resizeWidth), });