From 86983cd171f4bbc53a13cdeeadc42e24dac62f35 Mon Sep 17 00:00:00 2001 From: Guillaume Moutier Date: Mon, 10 Aug 2026 11:16:44 -0400 Subject: [PATCH] fix(bff,ui): prevent SSE write-after-close and improve stream error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During long-running Helm operations, intermediate proxies (RHOAI dashboard proxy, OpenShift Router/HAProxy) can sever the SSE connection. This caused ERR_INCOMPLETE_CHUNKED_ENCODING in the browser and a generic "Network error" in the progress modal, even though the operation completed successfully in the background. BFF: track connection state via res.on('close') and guard all res.write/res.end calls — prevents writing to a dead socket and leaking the heartbeat interval. Frontend: catch network errors from reader.read() during SSE streaming and surface a user-friendly "Connection lost" message instead of raw TypeError. Preserve last-known step progress in the modal on stream interruption. --- bff/src/routes/lifecycle.ts | 12 ++++++++++- src/app/hooks/usePluginLifecycle.ts | 31 ++++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/bff/src/routes/lifecycle.ts b/bff/src/routes/lifecycle.ts index 30b4441..d090af5 100644 --- a/bff/src/routes/lifecycle.ts +++ b/bff/src/routes/lifecycle.ts @@ -75,11 +75,19 @@ function sendSSE( 'X-Accel-Buffering': 'no', }); + let closed = false; + const heartbeat = setInterval(() => { - res.write(': keepalive\n\n'); + if (!closed) res.write(': keepalive\n\n'); }, 15_000); + res.on('close', () => { + closed = true; + clearInterval(heartbeat); + }); + const onProgress: LifecycleProgressCallback = (steps) => { + if (closed) return; const data = JSON.stringify({ steps: steps.map(s => ({ ...s })) }); res.write(`event: progress\ndata: ${data}\n\n`); }; @@ -87,11 +95,13 @@ function sendSSE( serviceFn(onProgress) .then((result) => { clearInterval(heartbeat); + if (closed) return; res.write(`event: complete\ndata: ${JSON.stringify(result)}\n\n`); res.end(); }) .catch(() => { clearInterval(heartbeat); + if (closed) return; const fallback: LifecycleResponse = { success: false, message: 'Operation failed', diff --git a/src/app/hooks/usePluginLifecycle.ts b/src/app/hooks/usePluginLifecycle.ts index 3bde1e8..0273548 100644 --- a/src/app/hooks/usePluginLifecycle.ts +++ b/src/app/hooks/usePluginLifecycle.ts @@ -3,6 +3,16 @@ import { LifecycleResponse, LifecycleStep, LifecycleOperation, LifecycleProgress const API_BASE = '/community-plugins-admin/api/plugins'; +class StreamInterruptedError extends Error { + constructor() { + super( + 'Connection lost during operation. The operation may still be running. ' + + 'Close this dialog and refresh to check the current status.', + ); + this.name = 'StreamInterruptedError'; + } +} + export interface PluginLifecycleState { loading: boolean; operation: LifecycleOperation | null; @@ -54,7 +64,13 @@ async function lifecycleStreamRequest( let finalResult: LifecycleResponse | null = null; for (;;) { - const { done, value } = await reader.read(); + let done: boolean; + let value: string | undefined; + try { + ({ done, value } = await reader.read()); + } catch { + throw new StreamInterruptedError(); + } if (done) break; buffer += value; @@ -87,7 +103,7 @@ async function lifecycleStreamRequest( } if (!finalResult) { - throw new Error('Stream ended without a complete event'); + throw new StreamInterruptedError(); } return finalResult; @@ -124,7 +140,16 @@ export function usePluginLifecycle(): PluginLifecycle { message, steps: [], }; - setState({ loading: false, operation, steps: [], result: failedResult, error: message }); + setState((prev) => ({ + loading: false, + operation, + steps: err instanceof StreamInterruptedError ? prev.steps : [], + result: { + ...failedResult, + steps: err instanceof StreamInterruptedError ? prev.steps : [], + }, + error: message, + })); return failedResult; } },