From d35ab6489cfdf34abdf6c0f0c9f5fd7f6156b10e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 18:15:36 +0000 Subject: [PATCH 1/2] cacheable-request - fix: keyv error listener leak on every request Keyv v5's event manager wraps once() listeners in an anonymous function, so the existing removeListener(error, errorHandler) cleanup never matched the registered wrapper and one error listener leaked per request, hitting MaxListenersExceededWarning after 100 requests. Register the handler with on() so the cleanup can remove the exact same reference, and also remove it on the underlying request's error/abort/ close events so aborted or destroyed requests that never emit response do not leak either. Fixes #1685 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011tF3VefnzwcH6TQo48uJHQ --- packages/cacheable-request/src/index.ts | 21 ++++++-- .../test/cacheable-request-instance.test.ts | 52 +++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/packages/cacheable-request/src/index.ts b/packages/cacheable-request/src/index.ts index 45e822d3..852ec3f7 100644 --- a/packages/cacheable-request/src/index.ts +++ b/packages/cacheable-request/src/index.ts @@ -315,12 +315,23 @@ class CacheableRequest { ee.emit("error", new CacheError(error)); if (this.cache instanceof Keyv) { const cachek = this.cache; - cachek.once("error", errorHandler); - ee.on("error", () => { - cachek.removeListener("error", errorHandler); - }); - ee.on("response", () => { + // Keyv's event manager wraps once() listeners, so removeListener() + // with the original function would never unregister them. Register + // with on() and remove the same reference on every termination path + // so the listener count stays bounded. + const removeErrorHandler = () => { cachek.removeListener("error", errorHandler); + }; + + cachek.on("error", errorHandler); + ee.on("error", removeErrorHandler); + ee.on("response", removeErrorHandler); + // Aborted or destroyed requests never emit "response", so clean up + // on the underlying request's termination events as well. + ee.on("request", (request_: any) => { + request_.once("error", removeErrorHandler); + request_.once("abort", removeErrorHandler); + request_.once("close", removeErrorHandler); }); } diff --git a/packages/cacheable-request/test/cacheable-request-instance.test.ts b/packages/cacheable-request/test/cacheable-request-instance.test.ts index 557a852f..525c6ca8 100644 --- a/packages/cacheable-request/test/cacheable-request-instance.test.ts +++ b/packages/cacheable-request/test/cacheable-request-instance.test.ts @@ -451,3 +451,55 @@ test("cacheableRequest emits CacheError when underlying Keyv emits error event", }); }); }); + +test("cacheableRequest removes Keyv error listener once the request settles", async () => { + const keyv = new Keyv(); + const cacheableRequest = new CacheableRequest(request, keyv).request(); + const baseline = keyv.listeners("error").length; + + const get = async (path: string) => + new Promise((resolve, reject) => { + cacheableRequest(parseWithWhatwg(`${s.url}${path}`), (response: any) => { + response.resume(); + response.once("end", resolve); + }) + .on("error", reject) + .on("request", (request_: any) => request_.end()); + }); + + // Repeated keys are served from cache while fresh keys hit the network; + // neither path may leave its error listener behind (issue #1685) + for (let index = 0; index < 6; index++) { + await get(`/?index=${index % 3}`); + } + + await new Promise((resolve) => { + setImmediate(resolve); + }); + expect(keyv.listeners("error").length).toBe(baseline); +}); + +test("cacheableRequest removes Keyv error listener when the request is aborted", async () => { + const keyv = new Keyv(); + const cacheableRequest = new CacheableRequest(request, keyv).request(); + const baseline = keyv.listeners("error").length; + + await new Promise((resolve) => { + cacheableRequest(parseWithWhatwg(s.url)) + .on("error", () => { + /* swallow abort-induced errors */ + }) + .on("request", (request_: any) => { + request_.on("error", () => { + /* swallow abort-induced socket errors */ + }); + request_.once("close", resolve); + request_.abort(); + }); + }); + + await new Promise((resolve) => { + setImmediate(resolve); + }); + expect(keyv.listeners("error").length).toBe(baseline); +}); From 43aec20dd95eb046e15ceaa0154e02967e683a80 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 18:23:23 +0000 Subject: [PATCH 2/2] cacheable-request - fix: detach request listeners when the keyv cleanup runs Track the in-flight request and remove its error/abort/close cleanup listeners inside removeErrorHandler, so the closure over the emitter is released deterministically once the request settles instead of waiting for the request object to be garbage collected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011tF3VefnzwcH6TQo48uJHQ --- packages/cacheable-request/src/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cacheable-request/src/index.ts b/packages/cacheable-request/src/index.ts index 852ec3f7..d5909ae4 100644 --- a/packages/cacheable-request/src/index.ts +++ b/packages/cacheable-request/src/index.ts @@ -319,8 +319,13 @@ class CacheableRequest { // with the original function would never unregister them. Register // with on() and remove the same reference on every termination path // so the listener count stays bounded. + let pendingRequest: any; const removeErrorHandler = () => { cachek.removeListener("error", errorHandler); + pendingRequest?.removeListener("error", removeErrorHandler); + pendingRequest?.removeListener("abort", removeErrorHandler); + pendingRequest?.removeListener("close", removeErrorHandler); + pendingRequest = undefined; }; cachek.on("error", errorHandler); @@ -329,6 +334,7 @@ class CacheableRequest { // Aborted or destroyed requests never emit "response", so clean up // on the underlying request's termination events as well. ee.on("request", (request_: any) => { + pendingRequest = request_; request_.once("error", removeErrorHandler); request_.once("abort", removeErrorHandler); request_.once("close", removeErrorHandler);