diff --git a/.changeset/send-web-response-drain-hang.md b/.changeset/send-web-response-drain-hang.md new file mode 100644 index 0000000..62c994b --- /dev/null +++ b/.changeset/send-web-response-drain-hang.md @@ -0,0 +1,5 @@ +--- +'vite-plugin-solid': patch +--- + +`sendWebResponse` no longer hangs forever when a client disconnects during backpressure. The write loop's `'drain'` wait had no other way to settle, but a response whose client already went away never emits `'drain'` — so every streamed SSR response aborted mid-stream (closed tab, slow mobile client) parked the promise chain, the body reader, and the Response object permanently, accumulating leaks over a turnkey dev/preview session. The backpressure wait now also settles on `'close'`/`'error'` and the loop bails out early once the response is destroyed, letting the existing close handler's reader cancellation finish cleanup. diff --git a/src/http.ts b/src/http.ts index 7e2b145..16bd6c6 100644 --- a/src/http.ts +++ b/src/http.ts @@ -56,8 +56,27 @@ export async function sendWebResponse(res: ServerResponse, response: Response): while (true) { const { done, value } = await reader.read(); if (done) break; + // A response whose client already went away never emits 'drain' + // (writes are no-ops), so a backpressure wait must also settle on + // 'close'/'error' or an aborted streaming response parks this promise + // — and the reader and Response it holds — forever. + if (res.destroyed) return; if (!res.write(value)) { - await new Promise((resolve) => res.once('drain', resolve)); + const drained = await new Promise((resolve) => { + const settle = (ok: boolean) => { + res.off('drain', onDrain); + res.off('close', onGone); + res.off('error', onGone); + resolve(ok); + }; + const onDrain = () => settle(true); + const onGone = () => settle(false); + res.once('drain', onDrain); + res.once('close', onGone); + res.once('error', onGone); + }); + // Client gone mid-stream; the 'close' handler cancels the reader. + if (!drained) return; } } res.end();