fix: upscale times out behind Cloudflare Tunnel due to blocking HTTP request

The upscale route held the HTTP connection open for the full duration of
Python sidecar processing (30-300s). Behind proxies with connection
timeouts (Cloudflare Tunnel: 100s), this caused HTTP 524 errors.

The route now returns 202 Accepted immediately after upload validation
and processes in the background. The result (downloadUrl, sizes, etc.)
is delivered via the existing SSE progress channel. The frontend detects
the 202 and waits for the SSE completion event instead of reading the
XHR response body. A reconnect-safe completion store ensures results
survive brief SSE disconnects.

Closes #106
This commit is contained in:
SnapOtter
2026-04-30 23:43:55 +08:00
parent 9d8c3f4027
commit 4900d8a4fe
4 changed files with 223 additions and 97 deletions
+21
View File
@@ -32,11 +32,15 @@ export interface SingleFileProgress {
stage?: string; stage?: string;
percent: number; percent: number;
error?: string; error?: string;
result?: Record<string, unknown>;
} }
/** In-memory store of job progress, keyed by jobId. */ /** In-memory store of job progress, keyed by jobId. */
const jobProgressStore = new Map<string, JobProgress>(); const jobProgressStore = new Map<string, JobProgress>();
/** Terminal single-file events kept for SSE reconnect replay. */
const singleFileCompletions = new Map<string, SingleFileProgress>();
/** SSE listeners waiting for updates, keyed by jobId. */ /** SSE listeners waiting for updates, keyed by jobId. */
const listeners = new Map<string, Set<(data: JobProgress | SingleFileProgress) => void>>(); const listeners = new Map<string, Set<(data: JobProgress | SingleFileProgress) => void>>();
@@ -183,6 +187,16 @@ export function updateJobProgress(progress: JobProgress): void {
export function updateSingleFileProgress(progress: Omit<SingleFileProgress, "type">): void { export function updateSingleFileProgress(progress: Omit<SingleFileProgress, "type">): void {
const event: SingleFileProgress = { ...progress, type: "single" }; const event: SingleFileProgress = { ...progress, type: "single" };
persistSingleFileProgress(progress); persistSingleFileProgress(progress);
if (progress.phase === "complete" || progress.phase === "failed") {
if (singleFileCompletions.size >= 10_000) {
const oldest = singleFileCompletions.keys().next().value;
if (oldest) singleFileCompletions.delete(oldest);
}
singleFileCompletions.set(progress.jobId, event);
setTimeout(() => singleFileCompletions.delete(progress.jobId), 120_000);
}
const subs = listeners.get(progress.jobId); const subs = listeners.get(progress.jobId);
if (subs) { if (subs) {
for (const cb of subs) { for (const cb of subs) {
@@ -228,6 +242,13 @@ export async function registerProgressRoutes(app: FastifyInstance): Promise<void
} }
} }
const existingSingle = singleFileCompletions.get(jobId);
if (existingSingle) {
sendEvent(existingSingle);
reply.raw.end();
return;
}
// Subscribe to updates // Subscribe to updates
if (!listeners.has(jobId)) { if (!listeners.has(jobId)) {
listeners.set(jobId, new Set()); listeners.set(jobId, new Set());
+87 -72
View File
@@ -81,36 +81,32 @@ export function registerUpscale(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
} }
let settings: z.infer<typeof settingsSchema>;
try { try {
let settings: z.infer<typeof settingsSchema>; const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
try { const result = settingsSchema.safeParse(parsed);
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; if (!result.success) {
const result = settingsSchema.safeParse(parsed); return reply
if (!result.success) { .status(400)
return reply .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
} }
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const scale = settings.scale; const scale = settings.scale;
const model = settings.model; const model = settings.model;
const faceEnhance = settings.faceEnhance; const faceEnhance = settings.faceEnhance;
const denoise = settings.denoise; const denoise = settings.denoise;
let format = settings.format; let format = settings.format;
const outputQuality = settings.quality; const outputQuality = settings.quality;
try {
if (format === "auto") { if (format === "auto") {
const detected = await resolveOutputFormat(fileBuffer, filename); const detected = await resolveOutputFormat(fileBuffer, filename);
format = detected.format === "jpeg" ? "jpg" : detected.format; format = detected.format === "jpeg" ? "jpg" : detected.format;
} }
request.log.info(
{ toolId: "upscale", imageSize: fileBuffer.length, scale, model, format },
"Starting upscale",
);
// Decode HEIC/HEIF input via system decoder // Decode HEIC/HEIF input via system decoder
if (validation.format === "heif") { if (validation.format === "heif") {
@@ -124,33 +120,54 @@ export function registerUpscale(app: FastifyInstance) {
// Auto-orient to fix EXIF rotation before upscaling // Auto-orient to fix EXIF rotation before upscaling
fileBuffer = await autoOrient(fileBuffer); fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "upscale" }, "Input decoding failed");
return reply.status(422).send({
error: "Upscaling failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID(); const originalSize = fileBuffer.length;
const workspacePath = await createWorkspace(jobId); const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
// Save input let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename); const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer); await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "upscale" }, "Workspace creation failed");
return reply.status(422).send({
error: "Upscaling failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Determine which format the Python sidecar should produce. const log = request.log;
// Formats that need Node.js-side conversion (HEIC/HEIF via heif-enc, log.info(
// AVIF via Sharp) are produced as PNG first, then converted below. { toolId: "upscale", imageSize: originalSize, scale, model, format },
const needsNodeConversion = ["heic", "heif", "avif"].includes(format); "Starting upscale",
const pythonFormat = needsNodeConversion ? "png" : format; );
// Process // Reply immediately so the HTTP connection closes within proxy timeout limits.
const jobIdForProgress = clientJobId; // The result will be delivered via the SSE progress channel.
const onProgress = jobIdForProgress reply.status(202).send({ jobId: progressJobId, async: true });
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
const needsNodeConversion = ["heic", "heif", "avif"].includes(format);
const pythonFormat = needsNodeConversion ? "png" : format;
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const result = await upscale( const result = await upscale(
fileBuffer, fileBuffer,
join(workspacePath, "output"), join(workspacePath, "output"),
@@ -158,7 +175,6 @@ export function registerUpscale(app: FastifyInstance) {
onProgress, onProgress,
); );
// Convert to final format if needed (HEIC/HEIF/AVIF)
let outputBuffer = result.buffer; let outputBuffer = result.buffer;
let finalFormat = result.format; let finalFormat = result.format;
if (needsNodeConversion) { if (needsNodeConversion) {
@@ -171,7 +187,6 @@ export function registerUpscale(app: FastifyInstance) {
} }
} }
// Save output with correct extension for the chosen format
const EXT_MAP: Record<string, string> = { const EXT_MAP: Record<string, string> = {
jpeg: "jpg", jpeg: "jpg",
jpg: "jpg", jpg: "jpg",
@@ -188,12 +203,10 @@ export function registerUpscale(app: FastifyInstance) {
const outputPath = join(workspacePath, "output", outputFilename); const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, outputBuffer); await writeFile(outputPath, outputBuffer);
// Generate browser-compatible preview for non-previewable formats
const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]); const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]);
let previewUrl: string | undefined; let previewUrl: string | undefined;
if (!BROWSER_PREVIEWABLE.has(finalFormat)) { if (!BROWSER_PREVIEWABLE.has(finalFormat)) {
try { try {
// For HEIC/HEIF, decode first since Sharp can't read HEVC
const previewInput = const previewInput =
finalFormat === "heic" || finalFormat === "heif" finalFormat === "heic" || finalFormat === "heif"
? await decodeHeic(outputBuffer) ? await decodeHeic(outputBuffer)
@@ -203,42 +216,44 @@ export function registerUpscale(app: FastifyInstance) {
await writeFile(previewPath, previewBuffer); await writeFile(previewPath, previewBuffer);
previewUrl = `/api/v1/download/${jobId}/preview.webp`; previewUrl = `/api/v1/download/${jobId}/preview.webp`;
} catch { } catch {
// Non-fatal - frontend will show fallback // Non-fatal
} }
} }
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
if (model !== "auto" && result.method !== model) { if (model !== "auto" && result.method !== model) {
request.log.warn( log.warn(
{ toolId: "upscale", requested: model, actual: result.method }, { toolId: "upscale", requested: model, actual: result.method },
`Upscale model mismatch: requested ${model} but used ${result.method}`, `Upscale model mismatch: requested ${model} but used ${result.method}`,
); );
} }
return reply.send({ const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
jobId, updateSingleFileProgress({
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`, jobId: progressJobId,
previewUrl, phase: "complete",
originalSize: fileBuffer.length, percent: 100,
processedSize: outputBuffer.length, result: {
width: result.width, jobId,
height: result.height, downloadUrl,
method: result.method, previewUrl,
originalSize,
processedSize: outputBuffer.length,
width: result.width,
height: result.height,
method: result.method,
},
}); });
} catch (err) {
request.log.error({ err, toolId: "upscale" }, "Upscaling failed"); log.info({ toolId: "upscale", jobId, downloadUrl }, "Upscale complete");
return reply.status(422).send({ })().catch((err) => {
error: "Upscaling failed", log.error({ err, toolId: "upscale" }, "Upscaling failed");
details: err instanceof Error ? err.message : "Unknown error", updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Upscale failed",
}); });
} });
}); });
// Register in the pipeline/batch registry so this tool can be used // Register in the pipeline/batch registry so this tool can be used
+47 -7
View File
@@ -94,6 +94,7 @@ export function useToolProcessor(toolId: string) {
// Generate client job ID for SSE correlation // Generate client job ID for SSE correlation
const clientJobId = generateId(); const clientJobId = generateId();
let asyncMode = false;
// For AI tools, open SSE before uploading // For AI tools, open SSE before uploading
if (isAiTool) { if (isAiTool) {
@@ -104,8 +105,42 @@ export function useToolProcessor(toolId: string) {
es.onmessage = (event) => { es.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.type === "single" && typeof data.percent === "number") { if (data.type !== "single") return;
// Scale server progress (0-100) into 15-100 range
if (data.phase === "complete" && data.result) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
es.close();
eventSourceRef.current = null;
const result = data.result as ProcessResult;
setWarning(result.warning ?? null);
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: result.downloadUrl,
processedPreviewUrl: result.previewUrl ?? null,
processedFilename: null,
status: "completed",
originalSize: result.originalSize,
processedSize: result.processedSize,
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
});
setProcessing(false);
setProgress(IDLE_PROGRESS);
return;
}
if (data.phase === "failed" && asyncMode) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
es.close();
eventSourceRef.current = null;
setError(data.error || "Processing failed");
setProcessing(false);
setProgress(IDLE_PROGRESS);
return;
}
if (typeof data.percent === "number") {
const scaled = 15 + (data.percent / 100) * 85; const scaled = 15 + (data.percent / 100) * 85;
setProgress((prev) => ({ setProgress((prev) => ({
...prev, ...prev,
@@ -120,11 +155,13 @@ export function useToolProcessor(toolId: string) {
}; };
es.onerror = () => { es.onerror = () => {
es.close(); if (!asyncMode) {
eventSourceRef.current = null; es.close();
eventSourceRef.current = null;
}
}; };
} catch { } catch {
// EventSource creation failed proceed without SSE // EventSource creation failed -- proceed without SSE
} }
} }
@@ -209,6 +246,11 @@ export function useToolProcessor(toolId: string) {
}; };
xhr.onload = () => { xhr.onload = () => {
if (xhr.status === 202) {
asyncMode = true;
return;
}
if (elapsedRef.current) clearInterval(elapsedRef.current); if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current); if (processingTimerRef.current) clearInterval(processingTimerRef.current);
if (eventSourceRef.current) { if (eventSourceRef.current) {
@@ -220,8 +262,6 @@ export function useToolProcessor(toolId: string) {
try { try {
const result: ProcessResult = JSON.parse(xhr.responseText); const result: ProcessResult = JSON.parse(xhr.responseText);
setWarning(result.warning ?? null); setWarning(result.warning ?? null);
// Write result to the entry that was being processed (captured at
// request time), not whatever entry happens to be selected now.
useFileStore.getState().updateEntry(capturedIndex, { useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: result.downloadUrl, processedUrl: result.downloadUrl,
processedPreviewUrl: result.previewUrl ?? null, processedPreviewUrl: result.previewUrl ?? null,
+68 -18
View File
@@ -1,9 +1,10 @@
/** /**
* Integration tests for the upscale tool (/api/v1/tools/upscale). * Integration tests for the upscale tool (/api/v1/tools/upscale).
* *
* This tool requires the Python sidecar (Real-ESRGAN). Tests accept both * This tool uses async processing: valid requests return 202 with a jobId,
* 200 (sidecar running) and 501 (not installed) for the processing path * and the result is delivered via SSE. Tests accept both 202 (processing
* while fully testing validation paths. * accepted) and 501 (not installed) for the processing path while fully
* testing validation paths.
*/ */
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
@@ -50,7 +51,7 @@ describe("Upscale", () => {
body, body,
}); });
expect([200, 501]).toContain(res.statusCode); expect([202, 501]).toContain(res.statusCode);
}, 60_000); }, 60_000);
it("accepts default settings (2x scale)", async () => { it("accepts default settings (2x scale)", async () => {
@@ -69,15 +70,12 @@ describe("Upscale", () => {
body, body,
}); });
expect([200, 501]).toContain(res.statusCode); expect([202, 501]).toContain(res.statusCode);
if (res.statusCode === 200) { if (res.statusCode === 202) {
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.jobId).toBeDefined();
expect(result.processedSize).toBeGreaterThan(0); expect(result.async).toBe(true);
expect(result.width).toBeDefined();
expect(result.height).toBeDefined();
expect(result.method).toBeDefined();
} }
if (res.statusCode === 501) { if (res.statusCode === 501) {
@@ -105,7 +103,7 @@ describe("Upscale", () => {
body, body,
}); });
expect([200, 501]).toContain(res.statusCode); expect([202, 501]).toContain(res.statusCode);
}, 60_000); }, 60_000);
it("accepts model and faceEnhance options", async () => { it("accepts model and faceEnhance options", async () => {
@@ -131,7 +129,7 @@ describe("Upscale", () => {
body, body,
}); });
expect([200, 501]).toContain(res.statusCode); expect([202, 501]).toContain(res.statusCode);
}, 60_000); }, 60_000);
it("accepts denoise and format options", async () => { it("accepts denoise and format options", async () => {
@@ -158,7 +156,7 @@ describe("Upscale", () => {
body, body,
}); });
expect([200, 501]).toContain(res.statusCode); expect([202, 501]).toContain(res.statusCode);
}, 60_000); }, 60_000);
it("accepts scale as a string (coerced to number)", async () => { it("accepts scale as a string (coerced to number)", async () => {
@@ -180,7 +178,7 @@ describe("Upscale", () => {
body, body,
}); });
expect([200, 501]).toContain(res.statusCode); expect([202, 501]).toContain(res.statusCode);
}, 60_000); }, 60_000);
it("processes JPEG input", async () => { it("processes JPEG input", async () => {
@@ -199,7 +197,7 @@ describe("Upscale", () => {
body, body,
}); });
expect([200, 501]).toContain(res.statusCode); expect([202, 501]).toContain(res.statusCode);
}, 60_000); }, 60_000);
it("handles HEIC input", async () => { it("handles HEIC input", async () => {
@@ -218,7 +216,7 @@ describe("Upscale", () => {
body, body,
}); });
expect([200, 501]).toContain(res.statusCode); expect([202, 501]).toContain(res.statusCode);
}, 60_000); }, 60_000);
it("handles 1x1 pixel input", async () => { it("handles 1x1 pixel input", async () => {
@@ -237,7 +235,7 @@ describe("Upscale", () => {
body, body,
}); });
expect([200, 422, 501]).toContain(res.statusCode); expect([202, 422, 501]).toContain(res.statusCode);
}, 60_000); }, 60_000);
// ── Validation (always testable) ───────────────────────────────── // ── Validation (always testable) ─────────────────────────────────
@@ -302,4 +300,56 @@ describe("Upscale", () => {
expect(res.statusCode).toBe(401); expect(res.statusCode).toBe(401);
}); });
// ── Async processing (regression for #106) ─────────────────────────
it("returns 202 with jobId for async processing", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({ scale: 2 }) },
{ name: "clientJobId", content: "test-job-async-regression" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/upscale",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
if (res.statusCode === 501) return;
expect(res.statusCode).toBe(202);
const result = JSON.parse(res.body);
expect(result.async).toBe(true);
expect(result.jobId).toBe("test-job-async-regression");
}, 60_000);
it("returns 202 without blocking for processing", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
{ name: "clientJobId", content: "test-job-timing" },
]);
const start = Date.now();
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/upscale",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
if (res.statusCode === 501) return;
const elapsed = Date.now() - start;
expect(res.statusCode).toBe(202);
expect(elapsed).toBeLessThan(30_000);
}, 60_000);
}); });