Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-dev-socket-listeners.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Clean up dev server socket close listeners after request handling
57 changes: 34 additions & 23 deletions packages/astro/src/vite-plugin-app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,39 +243,50 @@ export class AstroServerApp extends BaseApp<RunnablePipeline> {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>((resolve) => {
if (request.signal.aborted) {
resolve(true);
return;
}
const timeout = setTimeout(() => resolve(false), 5000);
const timeout = setTimeout(() => resolve(false), timeoutMs);
request.signal.addEventListener(
'abort',
() => {
Expand Down
35 changes: 35 additions & 0 deletions packages/astro/test/request-signal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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),
[],
);
});
});
Loading