fix: prevent SSE listener leak in console-logs stream (#751)
The ReadableStream cancel() callback is not reliably invoked on client disconnect under Next.js, causing emitter listeners (line/clear) and the keepalive interval to accumulate, eventually triggering MaxListenersExceededWarning. Use request.signal as the primary disconnect trigger, with cancel() and enqueue failures as fallbacks. Cleanup is idempotent via state.closed. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,10 +4,23 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
initConsoleLogCapture();
|
initConsoleLogCapture();
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request) {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const emitter = getConsoleEmitter();
|
const emitter = getConsoleEmitter();
|
||||||
const state = { closed: false, send: null, keepalive: null };
|
const state = { closed: false, send: null, sendClear: null, keepalive: null };
|
||||||
|
|
||||||
|
// Idempotent: safe to call from request.signal abort, cancel(), or enqueue failure.
|
||||||
|
const cleanup = () => {
|
||||||
|
if (state.closed) return;
|
||||||
|
state.closed = true;
|
||||||
|
if (state.send) emitter.off("line", state.send);
|
||||||
|
if (state.sendClear) emitter.off("clear", state.sendClear);
|
||||||
|
if (state.keepalive) clearInterval(state.keepalive);
|
||||||
|
};
|
||||||
|
|
||||||
|
// request.signal fires reliably on client disconnect; ReadableStream.cancel()
|
||||||
|
// is not always invoked in Next.js, which caused listeners to accumulate.
|
||||||
|
request.signal.addEventListener("abort", cleanup, { once: true });
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
@@ -23,7 +36,7 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "line", line })}\n\n`));
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "line", line })}\n\n`));
|
||||||
} catch {
|
} catch {
|
||||||
state.closed = true;
|
cleanup();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,7 +46,7 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "clear" })}\n\n`));
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "clear" })}\n\n`));
|
||||||
} catch {
|
} catch {
|
||||||
state.closed = true;
|
cleanup();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,17 +59,13 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
controller.enqueue(encoder.encode(": ping\n\n"));
|
controller.enqueue(encoder.encode(": ping\n\n"));
|
||||||
} catch {
|
} catch {
|
||||||
state.closed = true;
|
cleanup();
|
||||||
clearInterval(state.keepalive);
|
|
||||||
}
|
}
|
||||||
}, 25000);
|
}, 25000);
|
||||||
},
|
},
|
||||||
|
|
||||||
cancel() {
|
cancel() {
|
||||||
state.closed = true;
|
cleanup();
|
||||||
emitter.off("line", state.send);
|
|
||||||
emitter.off("clear", state.sendClear);
|
|
||||||
clearInterval(state.keepalive);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user