diff --git a/src/store/__tests__/workflowStore.integration.test.ts b/src/store/__tests__/workflowStore.integration.test.ts index 315f4701..e5744a7e 100644 --- a/src/store/__tests__/workflowStore.integration.test.ts +++ b/src/store/__tests__/workflowStore.integration.test.ts @@ -2307,4 +2307,135 @@ describe("workflowStore integration tests", () => { }); }); }); + + describe("Race condition prevention", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true, image: "data:image/png;base64,generated" }), + text: () => Promise.resolve(""), + }); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("should only execute nodes once when executeWorkflow is called concurrently", async () => { + useWorkflowStore.setState({ + nodes: [ + createTestNode("prompt-1", "prompt", { prompt: "test" }), + createTestNode("nanoBanana-1", "nanoBanana", { + aspectRatio: "1:1", + resolution: "1K", + model: "nano-banana", + }), + ], + edges: [ + createTestEdge("prompt-1", "nanoBanana-1", "text", "text"), + ], + }); + + const store = useWorkflowStore.getState(); + + // Fire two calls back-to-back without awaiting the first + const p1 = store.executeWorkflow(); + const p2 = store.executeWorkflow(); + await Promise.all([p1, p2]); + + // Only one execution should have reached fetch (one nanoBanana node) + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("should set isRunning synchronously before any await", async () => { + useWorkflowStore.setState({ + nodes: [ + createTestNode("prompt-1", "prompt", { prompt: "test" }), + createTestNode("nanoBanana-1", "nanoBanana", { + aspectRatio: "1:1", + resolution: "1K", + model: "nano-banana", + }), + ], + edges: [ + createTestEdge("prompt-1", "nanoBanana-1", "text", "text"), + ], + }); + + const store = useWorkflowStore.getState(); + expect(useWorkflowStore.getState().isRunning).toBe(false); + + // Call without awaiting — isRunning should be true synchronously + const promise = store.executeWorkflow(); + expect(useWorkflowStore.getState().isRunning).toBe(true); + + await promise; + }); + + it("should only execute once when regenerateNode is called concurrently", async () => { + useWorkflowStore.setState({ + nodes: [ + createTestNode("prompt-1", "prompt", { prompt: "test" }), + createTestNode("nanoBanana-1", "nanoBanana", { + aspectRatio: "1:1", + resolution: "1K", + model: "nano-banana", + inputImages: [], + outputImage: "data:image/png;base64,previous", + }), + ], + edges: [ + createTestEdge("prompt-1", "nanoBanana-1", "text", "text"), + ], + }); + + const store = useWorkflowStore.getState(); + + // Fire two calls back-to-back — second should be blocked by isRunning + const p1 = store.regenerateNode("nanoBanana-1"); + const p2 = store.regenerateNode("nanoBanana-1"); + await Promise.all([p1, p2]); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("should execute each node exactly once with multiple disconnected nodes", async () => { + useWorkflowStore.setState({ + nodes: [ + createTestNode("prompt-1", "prompt", { prompt: "a" }), + createTestNode("prompt-2", "prompt", { prompt: "b" }), + createTestNode("prompt-3", "prompt", { prompt: "c" }), + createTestNode("nanoBanana-1", "nanoBanana", { + aspectRatio: "1:1", + resolution: "1K", + model: "nano-banana", + }), + createTestNode("nanoBanana-2", "nanoBanana", { + aspectRatio: "1:1", + resolution: "1K", + model: "nano-banana", + }), + createTestNode("nanoBanana-3", "nanoBanana", { + aspectRatio: "1:1", + resolution: "1K", + model: "nano-banana", + }), + ], + edges: [ + createTestEdge("prompt-1", "nanoBanana-1", "text", "text"), + createTestEdge("prompt-2", "nanoBanana-2", "text", "text"), + createTestEdge("prompt-3", "nanoBanana-3", "text", "text"), + ], + }); + + const store = useWorkflowStore.getState(); + await store.executeWorkflow(); + + // Exactly 3 fetch calls — one per nanoBanana node + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + }); }); diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 5f5f736a..0104542b 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -882,12 +882,12 @@ export const useWorkflowStore = create((set, get) => ({ return; } - // Start logging session - await logger.startSession(); - const isResuming = startFromNodeId === get().pausedAtNodeId; set({ isRunning: true, pausedAtNodeId: null }); + // Start logging session + await logger.startSession(); + logger.info('workflow.start', 'Workflow execution started', { nodeCount: nodes.length, edgeCount: edges.length, @@ -1791,14 +1791,14 @@ export const useWorkflowStore = create((set, get) => ({ return; } + set({ isRunning: true, currentNodeId: nodeId }); + await logger.startSession(); logger.info('node.execution', 'Regenerating node', { nodeId, nodeType: node.type, }); - set({ isRunning: true, currentNodeId: nodeId }); - try { if (node.type === "nanoBanana") { // Get fresh node data from store