From dbaf25ba602da51d0bf90d1a1f2ab2c4f50a743c Mon Sep 17 00:00:00 2001 From: lapoaiscalers Date: Tue, 3 Feb 2026 11:53:22 +0100 Subject: [PATCH] fix: improve save reliability with timeouts and parallel processing - Add fetchWithTimeout utility for image save requests (30s timeout) - Process node images in parallel batches (3 at a time) for faster saves - Add timeout to waitForPendingImageSyncs (60s) to prevent infinite hangs - Move isSaving flag reset to finally block for consistent state cleanup Co-Authored-By: Claude Opus 4.5 --- src/store/workflowStore.ts | 23 ++++++++---- src/utils/imageStorage.ts | 75 +++++++++++++++++++++++++++++++------- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 62b4efa4..8c9c49db 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -296,10 +296,21 @@ function trackSaveGeneration( pendingImageSyncs.set(tempId, syncPromise); } -// Wait for all pending image syncs to complete -async function waitForPendingImageSyncs(): Promise { +// Wait for all pending image syncs to complete (with timeout to prevent infinite hangs) +async function waitForPendingImageSyncs(timeout: number = 60000): Promise { if (pendingImageSyncs.size === 0) return; - await Promise.all(pendingImageSyncs.values()); + + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + console.warn(`Pending image syncs timed out after ${timeout}ms, continuing with save`); + resolve(); + }, timeout); + }); + + await Promise.race([ + Promise.all(pendingImageSyncs.values()), + timeoutPromise, + ]); } // Concurrency settings @@ -3034,7 +3045,6 @@ export const useWorkflowStore = create((set, get) => ({ nodes: nodesWithRefs, lastSavedAt: timestamp, hasUnsavedChanges: false, - isSaving: false, // Update imageRefBasePath to reflect new save location imageRefBasePath: saveDirectoryPath, }); @@ -3042,7 +3052,6 @@ export const useWorkflowStore = create((set, get) => ({ set({ lastSavedAt: timestamp, hasUnsavedChanges: false, - isSaving: false, // Update imageRefBasePath to reflect save location imageRefBasePath: useExternalImageStorage ? saveDirectoryPath : null, }); @@ -3060,12 +3069,10 @@ export const useWorkflowStore = create((set, get) => ({ return true; } else { - set({ isSaving: false }); useToast.getState().show(`Auto-save failed: ${result.error}`, "error"); return false; } } catch (error) { - set({ isSaving: false }); useToast .getState() .show( @@ -3073,6 +3080,8 @@ export const useWorkflowStore = create((set, get) => ({ "error" ); return false; + } finally { + set({ isSaving: false }); } }, diff --git a/src/utils/imageStorage.ts b/src/utils/imageStorage.ts index 446d6d1b..b7f2799c 100644 --- a/src/utils/imageStorage.ts +++ b/src/utils/imageStorage.ts @@ -2,6 +2,38 @@ import { WorkflowNode, WorkflowNodeData } from "@/types"; import { WorkflowFile } from "@/store/workflowStore"; import crypto from "crypto"; +/** + * Fetch with timeout support using AbortController + * @param url - The URL to fetch + * @param options - Fetch options (RequestInit) + * @param timeout - Timeout in milliseconds (default: 30000ms / 30 seconds) + * @returns Promise + * @throws Error if the request times out or fails + */ +async function fetchWithTimeout( + url: string, + options: RequestInit, + timeout: number = 30000 +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + }); + return response; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`Request timed out after ${timeout}ms: ${url}`); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} + /** * Compute MD5 hash of image content for deduplication * Consistent with save-generation API (Phase 13 decision) @@ -34,12 +66,24 @@ export async function externalizeWorkflowImages( workflow: WorkflowFile, workflowPath: string ): Promise { - const externalizedNodes: WorkflowNode[] = []; const savedImageIds = new Map(); // base64 hash -> imageId (for deduplication) - for (const node of workflow.nodes) { - const newNode = await externalizeNodeImages(node, workflowPath, savedImageIds); - externalizedNodes.push(newNode); + // Process nodes in parallel batches with controlled concurrency + const BATCH_SIZE = 3; + const externalizedNodes: WorkflowNode[] = new Array(workflow.nodes.length); + + for (let i = 0; i < workflow.nodes.length; i += BATCH_SIZE) { + const batch = workflow.nodes.slice(i, i + BATCH_SIZE); + const results = await Promise.all( + batch.map((node, batchIndex) => + externalizeNodeImages(node, workflowPath, savedImageIds) + .then(result => ({ index: i + batchIndex, result })) + ) + ); + + for (const { index, result } of results) { + externalizedNodes[index] = result; + } } return { @@ -276,16 +320,19 @@ async function saveImageAndGetId( // Use existing ID if provided (for consistency with imageHistory), otherwise generate new const imageId = existingId || generateImageId(); - const response = await fetch("/api/workflow-images", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - workflowPath, - imageId, - imageData, - folder, - }), - }); + const response = await fetchWithTimeout( + "/api/workflow-images", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workflowPath, + imageId, + imageData, + folder, + }), + } + ); const result = await response.json();