From 39e1aa4d2e530615e1d84587ece389569099b83c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:24:52 +0000 Subject: [PATCH 1/3] Initial plan From 0da2d81332ea65e5640d0888a5e1e328f2f1c402 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:32:24 +0000 Subject: [PATCH 2/3] Cache https.Agent and Dispatcher instances per cluster/user pair in KubeConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes socket/FD leaks caused by creating a new https.Agent on every Watch reconnection or API call. - Add `agentCache` (Map keyed by "clusterName::userName") to reuse agent instances across repeated calls with the same cluster/user. - Add `dispatcherCache` (same key) to reuse undici Dispatcher instances across repeated applySecurityAuthentication() calls. - Add private `getAgentCacheKey()` helper that builds the key from the current cluster name and user name — consistent with the comment from @brendandburns about keying off the user/cluster tuple. - Add four new tests in config_test.ts verifying same-instance reuse and distinct instances for different cluster/user combinations. --- src/config.ts | 51 +++++++++++++++++++++++++++++++--- src/config_test.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/src/config.ts b/src/config.ts index 17a437ddb09..ced8fef39e3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -146,6 +146,16 @@ export class KubeConfig implements SecurityAuthentication { // List of custom authenticators that can be added by the user private custom_authenticators: Authenticator[] = []; + // Cache for https.Agent / proxy agent instances, keyed by "clusterName::userName" + private agentCache: Map = + new Map(); + + // Cache for undici Dispatcher instances, keyed by "clusterName::userName". + // The map value is `Dispatcher | undefined` because createDispatcher() may + // legitimately return undefined (when no TLS / proxy config is needed), and + // we still want to cache that fact to avoid redundant work on every call. + private dispatcherCache: Map = new Map(); + // Optionally add additional external authenticators, you must do this // before you load a kubeconfig file that references them. public addAuthenticator(authenticator: Authenticator): void { @@ -585,10 +595,28 @@ export class KubeConfig implements SecurityAuthentication { return this.getContextObject(this.currentContext); } + /** + * Returns a stable cache key for the current cluster/user pair. + * Agents and dispatchers are associated with a specific cluster (TLS endpoint) + * and user (client certificate / auth), so keying on the tuple avoids creating + * a new socket pool on every Watch reconnection or API call. + */ + private getAgentCacheKey(cluster: Cluster | null): string { + const clusterName = cluster?.name ?? ''; + const userName = this.getCurrentUser()?.name ?? ''; + return `${clusterName}::${userName}`; + } + private createAgent( cluster: Cluster | null, agentOptions: https.AgentOptions, ): https.Agent | SocksProxyAgent | HttpProxyAgent | HttpsProxyAgent { + const cacheKey = this.getAgentCacheKey(cluster); + const cached = this.agentCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + let agent: https.Agent | SocksProxyAgent | HttpProxyAgent | HttpsProxyAgent; if (cluster && cluster.proxyUrl) { @@ -612,6 +640,8 @@ export class KubeConfig implements SecurityAuthentication { } else { agent = new https.Agent(agentOptions); } + + this.agentCache.set(cacheKey, agent); return agent; } @@ -665,21 +695,34 @@ export class KubeConfig implements SecurityAuthentication { cluster: Cluster | null, agentOptions: https.AgentOptions, ): Dispatcher | undefined { + const cacheKey = this.getAgentCacheKey(cluster); + if (this.dispatcherCache.has(cacheKey)) { + return this.dispatcherCache.get(cacheKey); + } + const opts = this.createDispatcherOptions(cluster, agentOptions); + let dispatcher: Dispatcher | undefined; switch (opts.type) { case 'proxy': - return new UndiciProxyAgent({ + dispatcher = new UndiciProxyAgent({ uri: opts.uri, requestTls: opts.requestTls, connect: opts.connect, }); + break; case 'socks': - return new UndiciAgent({ connect: createSocksConnector(opts.uri, opts.requestTls) }); + dispatcher = new UndiciAgent({ connect: createSocksConnector(opts.uri, opts.requestTls) }); + break; case 'agent': - return new UndiciAgent({ connect: opts.connect }); + dispatcher = new UndiciAgent({ connect: opts.connect }); + break; case 'none': - return undefined; + dispatcher = undefined; + break; } + + this.dispatcherCache.set(cacheKey, dispatcher); + return dispatcher; } private applyHTTPSOptions(opts: https.RequestOptions | WebSocket.ClientOptions): void { diff --git a/src/config_test.ts b/src/config_test.ts index 1c26d924654..61787d08d94 100644 --- a/src/config_test.ts +++ b/src/config_test.ts @@ -642,6 +642,74 @@ describe('KubeConfig', () => { }); }); + describe('agent and dispatcher caching', () => { + it('should return the same https.Agent instance on repeated applyToHTTPSOptions calls', async () => { + const kc = new KubeConfig(); + kc.loadFromFile(kcFileName); + + const opts1: https.RequestOptions = {}; + const opts2: https.RequestOptions = {}; + await kc.applyToHTTPSOptions(opts1); + await kc.applyToHTTPSOptions(opts2); + + strictEqual(opts1.agent, opts2.agent, 'Expected the same agent instance to be reused'); + }); + + it('should return different https.Agent instances for different cluster/user combinations', async () => { + const kc = new KubeConfig(); + kc.loadFromFile(kcFileName); + + // Default context uses one user + const opts1: https.RequestOptions = {}; + await kc.applyToHTTPSOptions(opts1); + + // Switch to a context with a different user + kc.setCurrentContext('passwd'); + const opts2: https.RequestOptions = {}; + await kc.applyToHTTPSOptions(opts2); + + notStrictEqual( + opts1.agent, + opts2.agent, + 'Expected distinct agent instances for different cluster/user pairs', + ); + }); + + it('should return the same dispatcher instance on repeated applySecurityAuthentication calls', async () => { + const kc = new KubeConfig(); + kc.loadFromFile(kcFileName); + + const rc1 = new RequestContext('https://example.com', HttpMethod.GET); + const rc2 = new RequestContext('https://example.com', HttpMethod.GET); + await kc.applySecurityAuthentication(rc1); + await kc.applySecurityAuthentication(rc2); + + strictEqual( + rc1.getDispatcher(), + rc2.getDispatcher(), + 'Expected the same dispatcher instance to be reused', + ); + }); + + it('should return different dispatcher instances for different cluster/user combinations', async () => { + const kc = new KubeConfig(); + kc.loadFromFile(kcTlsServerNameFileName); + + const rc1 = new RequestContext('https://kube.example.com', HttpMethod.GET); + await kc.applySecurityAuthentication(rc1); + + kc.setCurrentContext('passwd'); + const rc2 = new RequestContext('https://example.com', HttpMethod.GET); + await kc.applySecurityAuthentication(rc2); + + notStrictEqual( + rc1.getDispatcher(), + rc2.getDispatcher(), + 'Expected distinct dispatcher instances for different cluster/user pairs', + ); + }); + }); + describe('loadClusterConfigObjects', () => { it('should fail if name is missing from cluster', () => { throws( From fdc776394e8daa30b73bd871e8064ee11f084b6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:34:17 +0000 Subject: [PATCH 3/3] Clarify none case in createDispatcher cache handling --- src/config.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/config.ts b/src/config.ts index ced8fef39e3..b4bde418a1f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -701,7 +701,16 @@ export class KubeConfig implements SecurityAuthentication { } const opts = this.createDispatcherOptions(cluster, agentOptions); - let dispatcher: Dispatcher | undefined; + + // Explicitly handle the no-dispatcher case so it is clear that caching + // undefined is intentional (avoids re-running createDispatcherOptions on + // every call when no TLS / proxy config is needed). + if (opts.type === 'none') { + this.dispatcherCache.set(cacheKey, undefined); + return undefined; + } + + let dispatcher: Dispatcher; switch (opts.type) { case 'proxy': dispatcher = new UndiciProxyAgent({ @@ -716,9 +725,6 @@ export class KubeConfig implements SecurityAuthentication { case 'agent': dispatcher = new UndiciAgent({ connect: opts.connect }); break; - case 'none': - dispatcher = undefined; - break; } this.dispatcherCache.set(cacheKey, dispatcher);