From b792da53ba623fd326f1aeaa18060a862a85986d Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 7 Oct 2026 19:49:29 -0700 Subject: [PATCH] fix: read thumbnail generation progress over POST with SseStream images/generate-thumbnails/progress is becoming POST-only, which the browser EventSource cannot request. Both thumbnail progress consumers in the gallery now use SseStream.connect(url, {method: 'POST'}) exactly as the autotag streams do. SseStream gains addEventListener/removeEventListener for named event: frames (tested) so the status, start, progress, file_error, complete and error listeners stay as they were, and an onclose guard rejects when the stream ends without a terminal frame. --- tests/js/sse-stream.test.js | 50 +++++++++++++++++++++++++++++++++++++ web/js/gallery.js | 25 ++++++++++++++++--- web/js/sse-stream.js | 32 ++++++++++++++++++++---- 3 files changed, 98 insertions(+), 9 deletions(-) diff --git a/tests/js/sse-stream.test.js b/tests/js/sse-stream.test.js index 2c5465f..6aa3b48 100644 --- a/tests/js/sse-stream.test.js +++ b/tests/js/sse-stream.test.js @@ -160,3 +160,53 @@ test("connect defaults to GET and the global fetch", async () => { globalThis.fetch = original; } }); + +test("addEventListener receives named events and unnamed ones go to 'message'", async () => { + const named = []; + const plain = []; + let closed = false; + const fetchImpl = async () => + new Response( + streamOf(['event: start\ndata: {"total":3}\n\n', 'data: {"n":1}\n\n', 'event: complete\ndata: {"ok":true}\n\n']), + { status: 200 }, + ); + const source = connect("/prompt_manager/images/generate-thumbnails/progress", { method: "POST", fetchImpl }); + source.addEventListener("start", (e) => named.push(["start", JSON.parse(e.data).total])); + source.addEventListener("complete", (e) => named.push(["complete", JSON.parse(e.data).ok])); + source.addEventListener("message", (e) => plain.push(JSON.parse(e.data).n)); + source.onclose = () => { closed = true; }; + await until(() => closed); + assert.deepEqual(named, [["start", 3], ["complete", true]]); + assert.deepEqual(plain, [1]); +}); + +test("removeEventListener stops delivery and onmessage still sees every event", async () => { + const all = []; + const progress = []; + let closed = false; + const fetchImpl = async () => + new Response(streamOf(["event: progress\ndata: 1\n\n", "event: progress\ndata: 2\n\n"]), { status: 200 }); + const source = connect("/x", { fetchImpl }); + const listener = (e) => { + progress.push(e.data); + source.removeEventListener("progress", listener); + }; + source.addEventListener("progress", listener); + source.onmessage = (e) => all.push(`${e.event}:${e.data}`); + source.onclose = () => { closed = true; }; + await until(() => closed); + assert.deepEqual(progress, ["1"]); + assert.deepEqual(all, ["progress:1", "progress:2"]); +}); + +test("a listener that closes the source stops later events", async () => { + const seen = []; + const fetchImpl = async () => + new Response(streamOf(["event: error\ndata: boom\n\n", "event: progress\ndata: 2\n\n"]), { status: 200 }); + const source = connect("/x", { fetchImpl }); + source.addEventListener("error", (e) => { seen.push(e.data); source.close(); }); + source.addEventListener("progress", (e) => seen.push(e.data)); + await until(() => source.readyState === source.CLOSED); + await new Promise((r) => setTimeout(r, 10)); + assert.deepEqual(seen, ["boom"]); +}); diff --git a/web/js/gallery.js b/web/js/gallery.js index 73e31ab..abe037d 100644 --- a/web/js/gallery.js +++ b/web/js/gallery.js @@ -1840,7 +1840,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; // Set up Server-Sent Events - const eventSource = new EventSource(`/prompt_manager/images/generate-thumbnails/progress?quality=${options.quality}`); + // The progress route is POST (it generates files), so read it with SseStream like the autotag streams + const eventSource = SseStream.connect(`/prompt_manager/images/generate-thumbnails/progress?quality=${encodeURIComponent(options.quality)}`, { method: 'POST' }); // Log connection @@ -1993,7 +1994,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; console.error('EventSource readyState:', eventSource.readyState); // Check if connection is closing normally - if (eventSource.readyState === EventSource.CLOSED) { + if (eventSource.readyState === eventSource.CLOSED) { } else { console.error('EventSource connection failed'); } @@ -2008,6 +2009,14 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; reject(new Error('Connection to server lost during thumbnail generation')); } }; + + // Stream ended without a complete/error frame (close() does not trigger this) + eventSource.onclose = () => { + if (cancelled || resultData) return; + cancelBtn.removeEventListener('click', cancelHandler); + cancelBtn.classList.add('hidden'); + reject(new Error('Connection to server closed before thumbnail generation finished')); + }; }); } @@ -2580,7 +2589,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; // Set up Server-Sent Events (same as the settings modal) - const eventSource = new EventSource(`/prompt_manager/images/generate-thumbnails/progress?quality=${quality}`); + // The progress route is POST (it generates files), so read it with SseStream like the autotag streams + const eventSource = SseStream.connect(`/prompt_manager/images/generate-thumbnails/progress?quality=${encodeURIComponent(quality)}`, { method: 'POST' }); let resultData = null; @@ -2750,7 +2760,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; console.error('EventSource connection error in modal:', error); // Check if connection is closing normally (after complete event) - if (eventSource.readyState === EventSource.CLOSED && resultData) { + if (eventSource.readyState === eventSource.CLOSED && resultData) { return; } @@ -2765,6 +2775,13 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; reject(new Error('Connection to server lost during thumbnail generation')); } }; + + // Stream ended without a complete/error frame (close() does not trigger this) + eventSource.onclose = () => { + if (isCancelled() || resultData) return; + addStatusMessage('Connection to server closed before thumbnail generation finished', 'error'); + reject(new Error('Connection to server closed before thumbnail generation finished')); + }; }); } diff --git a/web/js/sse-stream.js b/web/js/sse-stream.js index 0017c94..976374d 100644 --- a/web/js/sse-stream.js +++ b/web/js/sse-stream.js @@ -1,10 +1,12 @@ /** * Server-sent events over fetch. * - * EventSource can only GET. The auto-tag download/start endpoints are POST - * (they have side effects), so their progress streams are read with fetch and - * a small parser. `connect()` returns an object with the EventSource surface - * the pages already use: onmessage, onerror, onclose, readyState, close(). + * EventSource can only GET. The auto-tag download/start and thumbnail + * generation endpoints are POST (they have side effects), so their progress + * streams are read with fetch and a small parser. `connect()` returns an + * object with the EventSource surface the pages already use: onmessage, + * onerror, onclose, readyState, close(), and addEventListener(name, fn) for + * named `event:` frames. * * Loadable in the browser (window.SseStream) and in Node for tests. */ @@ -60,6 +62,7 @@ function connect(url, { method = "GET", body, fetchImpl } = {}) { const doFetch = fetchImpl || ((...args) => globalThis.fetch(...args)); const controller = new AbortController(); + const listeners = new Map(); const source = { onmessage: null, onerror: null, @@ -73,8 +76,27 @@ source.readyState = CLOSED; controller.abort(); }, + /** Named `event:` frames reach listeners for that name; unnamed ones reach "message". */ + addEventListener(name, listener) { + if (!listeners.has(name)) listeners.set(name, new Set()); + listeners.get(name).add(listener); + }, + removeEventListener(name, listener) { + const set = listeners.get(name); + if (set) set.delete(listener); + }, }; + function deliver(event) { + if (source.onmessage) source.onmessage(event); + const set = listeners.get(event.event || "message"); + if (!set) return; + for (const listener of Array.from(set)) { + if (source.readyState === CLOSED) return; + listener(event); + } + } + (async () => { try { const init = { method, headers: { Accept: "text/event-stream" }, signal: controller.signal }; @@ -95,7 +117,7 @@ buffer = rest; for (const event of events) { if (source.readyState === CLOSED) return; - if (source.onmessage) source.onmessage(event); + deliver(event); } if (done) break; }