From 76bc210cfed2a16a272ef84075a5913a1de6abf0 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 6 Jul 2026 22:04:36 +0700 Subject: [PATCH] Fix dev request signal listener cleanup --- .changeset/clean-dev-socket-listeners.md | 5 ++ packages/astro/src/vite-plugin-app/app.ts | 57 +++++++++++-------- .../src/pages/dev-signal-test.ts | 6 +- packages/astro/test/request-signal.test.ts | 35 ++++++++++++ 4 files changed, 78 insertions(+), 25 deletions(-) create mode 100644 .changeset/clean-dev-socket-listeners.md diff --git a/.changeset/clean-dev-socket-listeners.md b/.changeset/clean-dev-socket-listeners.md new file mode 100644 index 000000000000..f69a7f982ccc --- /dev/null +++ b/.changeset/clean-dev-socket-listeners.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Clean up dev server socket close listeners after request handling diff --git a/packages/astro/src/vite-plugin-app/app.ts b/packages/astro/src/vite-plugin-app/app.ts index f33abc616749..b151a09404fa 100644 --- a/packages/astro/src/vite-plugin-app/app.ts +++ b/packages/astro/src/vite-plugin-app/app.ts @@ -243,39 +243,50 @@ export class AstroServerApp extends BaseApp { abortController.abort(); } }; + let removeSocketCloseListener = () => {}; if (socket.destroyed) { onSocketClose(); } else { socket.on('close', onSocketClose); + removeSocketCloseListener = () => { + socket.removeListener('close', onSocketClose); + }; + abortController.signal.addEventListener('abort', removeSocketCloseListener, { + once: true, + }); } - const request = createRequest({ - url, - headers: incomingRequest.headers, - method: incomingRequest.method, - body, - logger: self.logger, - isPrerendered: matchedRoute.routeData.prerender, - routePattern: matchedRoute.routeData.component, - init: { signal: abortController.signal }, - }); + try { + const request = createRequest({ + url, + headers: incomingRequest.headers, + method: incomingRequest.method, + body, + logger: self.logger, + isPrerendered: matchedRoute.routeData.prerender, + routePattern: matchedRoute.routeData.component, + init: { signal: abortController.signal }, + }); - // This is required for adapters to set locals in dev mode. They use a dev server middleware to inject locals to the `http.IncomingRequest` object. - const locals = Reflect.get(incomingRequest, clientLocalsSymbol); + // This is required for adapters to set locals in dev mode. They use a dev server middleware to inject locals to the `http.IncomingRequest` object. + const locals = Reflect.get(incomingRequest, clientLocalsSymbol); - // Set user specified headers to response object. - for (const [name, value] of Object.entries(self.settings.config.server.headers ?? {})) { - if (value) incomingResponse.setHeader(name, value); - } - const clientAddress = incomingRequest.socket.remoteAddress; + // Set user specified headers to response object. + for (const [name, value] of Object.entries(self.settings.config.server.headers ?? {})) { + if (value) incomingResponse.setHeader(name, value); + } + const clientAddress = incomingRequest.socket.remoteAddress; - const response = await self.render(request, { - locals, - routeData: matchedRoute.routeData, - clientAddress, - }); + const response = await self.render(request, { + locals, + routeData: matchedRoute.routeData, + clientAddress, + }); - await writeSSRResult(request, response, incomingResponse); + await writeSSRResult(request, response, incomingResponse); + } finally { + removeSocketCloseListener(); + } }, onError(_err) { const error = createSafeError(_err); diff --git a/packages/astro/test/fixtures/request-signal/src/pages/dev-signal-test.ts b/packages/astro/test/fixtures/request-signal/src/pages/dev-signal-test.ts index f4405fa30fd3..c8a50eb4f7f5 100644 --- a/packages/astro/test/fixtures/request-signal/src/pages/dev-signal-test.ts +++ b/packages/astro/test/fixtures/request-signal/src/pages/dev-signal-test.ts @@ -2,14 +2,16 @@ import type { APIContext } from 'astro'; export const prerender = false; -export const GET = async ({ request }: APIContext) => { +export const GET = async ({ request, url }: APIContext) => { + const timeoutMs = Number(url.searchParams.get('timeout') ?? 5000); + // Wait until the signal is aborted or 5 seconds pass const aborted = await new Promise((resolve) => { if (request.signal.aborted) { resolve(true); return; } - const timeout = setTimeout(() => resolve(false), 5000); + const timeout = setTimeout(() => resolve(false), timeoutMs); request.signal.addEventListener( 'abort', () => { diff --git a/packages/astro/test/request-signal.test.ts b/packages/astro/test/request-signal.test.ts index 544cae2f881f..cf894a2d8c65 100644 --- a/packages/astro/test/request-signal.test.ts +++ b/packages/astro/test/request-signal.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events'; import { after, before, describe, it } from 'node:test'; import { setTimeout as delay } from 'node:timers/promises'; import type { IncomingMessage, ServerResponse } from 'node:http'; +import { Agent } from 'undici'; import { createRequestAndResponse } from './integration-test-helpers.ts'; import { type DevServer, type Fixture, loadFixture, type RequestHandler } from './test-utils.ts'; @@ -167,4 +168,38 @@ describe('Dev server request abort signal', () => { const normalPayload = await normalRes.json(); assert.equal(normalPayload.aborted, false, 'non-aborted request should report aborted=false'); }); + + it('does not retain socket close listeners after successful requests', async () => { + const warnings: Error[] = []; + const onWarning = (warning: Error) => { + if ( + warning.name === 'MaxListenersExceededWarning' && + warning.message.includes('close listeners') + ) { + warnings.push(warning); + } + }; + + process.on('warning', onWarning); + const dispatcher = new Agent({ connections: 1 }); + + try { + for (let i = 0; i < 12; i++) { + const response = await fixture.fetch('/dev-signal-test?timeout=1', { + dispatcher, + } as RequestInit); + assert.equal(response.status, 200); + await response.arrayBuffer(); + } + await delay(50); + } finally { + process.off('warning', onWarning); + await dispatcher.close(); + } + + assert.deepEqual( + warnings.map((warning) => warning.message), + [], + ); + }); });