From 68afa9d6d5d40bc9fc622e246cb4c67806f920ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 17:40:24 +0000 Subject: [PATCH 1/5] Initial plan From 612eda9cf1562bf88a8e9df0a4c21252bdb582b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 17:46:01 +0000 Subject: [PATCH 2/5] Return watch abort controller before network response --- src/watch.ts | 75 ++++++++++++++++++++++++----------------------- src/watch_test.ts | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 36 deletions(-) diff --git a/src/watch.ts b/src/watch.ts index 85058117e2e..21bc5a8c277 100644 --- a/src/watch.ts +++ b/src/watch.ts @@ -44,7 +44,6 @@ export class Watch { const signal = AbortSignal.any([controller.signal, timeoutSignal]); const ctx = new RequestContext(watchURL.toString(), HttpMethod.GET); - await this.config.applySecurityAuthentication(ctx); let doneCalled: boolean = false; const doneCallOnce = (err: any) => { @@ -59,45 +58,49 @@ export class Watch { } }; - try { - const response = await fetch(watchURL, { - method: 'GET', - headers: ctx.getHeaders(), - dispatcher: ctx.getDispatcher(), - signal, - }); + (async () => { + try { + await this.config.applySecurityAuthentication(ctx); - if (response.status === 200) { - const body = Readable.fromWeb(response.body! as any); + const response = await fetch(watchURL, { + method: 'GET', + headers: ctx.getHeaders(), + dispatcher: ctx.getDispatcher(), + signal, + }); - body.on('error', doneCallOnce); - body.on('close', () => doneCallOnce(null)); - body.on('finish', () => doneCallOnce(null)); + if (response.status === 200) { + const body = Readable.fromWeb(response.body! as any); - const lines = createInterface(body); - lines.on('error', doneCallOnce); - lines.on('close', () => doneCallOnce(null)); - lines.on('finish', () => doneCallOnce(null)); - lines.on('line', (line) => { - try { - const data = JSON.parse(line.toString()); - callback(data.type, data.object, data); - } catch { - // ignore parse errors - } - }); - } else { - const statusText = - response.statusText || STATUS_CODES[response.status] || 'Internal Server Error'; - const error = new Error(statusText) as Error & { - statusCode: number | undefined; - }; - error.statusCode = response.status; - throw error; + body.on('error', doneCallOnce); + body.on('close', () => doneCallOnce(null)); + body.on('finish', () => doneCallOnce(null)); + + const lines = createInterface(body); + lines.on('error', doneCallOnce); + lines.on('close', () => doneCallOnce(null)); + lines.on('finish', () => doneCallOnce(null)); + lines.on('line', (line) => { + try { + const data = JSON.parse(line.toString()); + callback(data.type, data.object, data); + } catch { + // ignore parse errors + } + }); + } else { + const statusText = + response.statusText || STATUS_CODES[response.status] || 'Internal Server Error'; + const error = new Error(statusText) as Error & { + statusCode: number | undefined; + }; + error.statusCode = response.status; + throw error; + } + } catch (err) { + doneCallOnce(err); } - } catch (err) { - doneCallOnce(err); - } + })(); return controller; } diff --git a/src/watch_test.ts b/src/watch_test.ts index 4aad61a05d9..f86d22338b0 100644 --- a/src/watch_test.ts +++ b/src/watch_test.ts @@ -65,6 +65,10 @@ describe('Watch', () => { let doneCalled = false; let doneErr: any; + let doneResolve: () => void; + const donePromise = new Promise((resolve) => { + doneResolve = resolve; + }); await watch.watch( path, @@ -73,8 +77,10 @@ describe('Watch', () => { (err: any) => { doneCalled = true; doneErr = err; + doneResolve(); }, ); + await donePromise; strictEqual(doneCalled, true); strictEqual(doneErr.toString(), 'Error: Internal Server Error'); mockAgent.assertNoPendingInterceptors(); @@ -386,6 +392,53 @@ describe('Watch', () => { strictEqual(doneErr.name, 'TimeoutError'); }); + it('should return abort controller before receiving response data', async (t) => { + const kc = await setupMockSystem(t, (_req: any, _res: any) => { + // Keep connection open without responding immediately. + }); + const watch = new Watch(kc); + + let doneErr: any; + + let doneResolve: () => void; + const donePromise = new Promise((resolve) => { + doneResolve = resolve; + }); + + const controllerPromise = watch.watch( + '/some/path/to/object', + {}, + () => { + throw new Error('Unexpected data received'); + }, + (err: any) => { + doneErr = err; + doneResolve(); + }, + ); + + const controller = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('watch() did not return AbortController in time')); + }, 100); + + controllerPromise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (err) => { + clearTimeout(timeout); + reject(err); + }, + ); + }); + + controller.abort(); + await donePromise; + strictEqual(doneErr?.name, 'AbortError'); + }); + it('should throw on empty config', async () => { const kc = new KubeConfig(); const watch = new Watch(kc); From e785fa2c98ab9788a1fd8bf793d7ab589efd0406 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 17:51:55 +0000 Subject: [PATCH 3/5] Make watch return abort controller immediately --- src/watch.ts | 5 +++-- src/watch_test.ts | 34 +++++++++++++--------------------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/src/watch.ts b/src/watch.ts index 21bc5a8c277..12e2df6a2c9 100644 --- a/src/watch.ts +++ b/src/watch.ts @@ -58,7 +58,7 @@ export class Watch { } }; - (async () => { + const startWatch = async (): Promise => { try { await this.config.applySecurityAuthentication(ctx); @@ -100,7 +100,8 @@ export class Watch { } catch (err) { doneCallOnce(err); } - })(); + }; + startWatch().catch(doneCallOnce); return controller; } diff --git a/src/watch_test.ts b/src/watch_test.ts index f86d22338b0..b470f0c3c24 100644 --- a/src/watch_test.ts +++ b/src/watch_test.ts @@ -65,7 +65,7 @@ describe('Watch', () => { let doneCalled = false; let doneErr: any; - let doneResolve: () => void; + let doneResolve!: () => void; const donePromise = new Promise((resolve) => { doneResolve = resolve; }); @@ -176,7 +176,7 @@ describe('Watch', () => { const watch = new Watch(kc); let doneCalled = 0; - let doneResolve: () => void; + let doneResolve!: () => void; const donePromise = new Promise((resolve) => { doneResolve = resolve; @@ -370,7 +370,7 @@ describe('Watch', () => { let doneErr: any; - let doneResolve: () => void; + let doneResolve!: () => void; const donePromise = new Promise((resolve) => { doneResolve = resolve; }); @@ -394,13 +394,13 @@ describe('Watch', () => { it('should return abort controller before receiving response data', async (t) => { const kc = await setupMockSystem(t, (_req: any, _res: any) => { - // Keep connection open without responding immediately. + // Intentionally do not write headers/body so fetch stays pending. }); const watch = new Watch(kc); let doneErr: any; - let doneResolve: () => void; + let doneResolve!: () => void; const donePromise = new Promise((resolve) => { doneResolve = resolve; }); @@ -417,22 +417,14 @@ describe('Watch', () => { }, ); - const controller = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('watch() did not return AbortController in time')); - }, 100); - - controllerPromise.then( - (value) => { - clearTimeout(timeout); - resolve(value); - }, - (err) => { - clearTimeout(timeout); - reject(err); - }, - ); - }); + const controller = await Promise.race([ + controllerPromise, + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('watch() did not return AbortController in time')); + }, 100); + }), + ]); controller.abort(); await donePromise; From e158e8e4f7c3368a64125b944a1e86b3a211f762 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 03:12:37 +0000 Subject: [PATCH 4/5] Handle aborts before watch fetch initialization --- src/watch.ts | 14 ++++++++++++++ src/watch_test.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/watch.ts b/src/watch.ts index 12e2df6a2c9..b67f5a44645 100644 --- a/src/watch.ts +++ b/src/watch.ts @@ -59,8 +59,20 @@ export class Watch { }; const startWatch = async (): Promise => { + const onAbort = () => { + doneCallOnce(new DOMException('This operation was aborted', 'AbortError')); + }; try { + signal.addEventListener('abort', onAbort); + if (signal.aborted) { + onAbort(); + return; + } + await this.config.applySecurityAuthentication(ctx); + if (signal.aborted) { + return; + } const response = await fetch(watchURL, { method: 'GET', @@ -99,6 +111,8 @@ export class Watch { } } catch (err) { doneCallOnce(err); + } finally { + signal.removeEventListener('abort', onAbort); } }; startWatch().catch(doneCallOnce); diff --git a/src/watch_test.ts b/src/watch_test.ts index b470f0c3c24..ac70ef7b0d8 100644 --- a/src/watch_test.ts +++ b/src/watch_test.ts @@ -431,6 +431,49 @@ describe('Watch', () => { strictEqual(doneErr?.name, 'AbortError'); }); + it('should abort before fetch starts when controller is aborted early', async (t) => { + let requestReceived = false; + const kc = await setupMockSystem(t, (_req: any, _res: any) => { + requestReceived = true; + }); + const watch = new Watch(kc); + const originalApplySecurityAuthentication = watch.config.applySecurityAuthentication.bind( + watch.config, + ); + watch.config.applySecurityAuthentication = async (ctx: any) => { + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + await originalApplySecurityAuthentication(ctx); + }; + + let doneErr: any; + let doneResolve!: () => void; + const donePromise = new Promise((resolve) => { + doneResolve = resolve; + }); + + const controller = await watch.watch( + '/some/path/to/object', + {}, + () => { + throw new Error('Unexpected data received'); + }, + (err: any) => { + doneErr = err; + doneResolve(); + }, + ); + + controller.abort(); + await donePromise; + await new Promise((resolve) => { + setTimeout(resolve, 75); + }); + strictEqual(doneErr?.name, 'AbortError'); + strictEqual(requestReceived, false); + }); + it('should throw on empty config', async () => { const kc = new KubeConfig(); const watch = new Watch(kc); From 360704850c4d01b89ef2974ea09531d5b2d04c69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 03:19:56 +0000 Subject: [PATCH 5/5] Abort watches cleanly before fetch initialization --- src/watch.ts | 18 ++++++++++++------ src/watch_test.ts | 9 ++++++--- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/watch.ts b/src/watch.ts index b67f5a44645..130b0b20395 100644 --- a/src/watch.ts +++ b/src/watch.ts @@ -59,18 +59,22 @@ export class Watch { }; const startWatch = async (): Promise => { + const abortError = new DOMException('This operation was aborted', 'AbortError'); const onAbort = () => { - doneCallOnce(new DOMException('This operation was aborted', 'AbortError')); + doneCallOnce(abortError); }; + let abortListenerAdded = false; + if (signal.aborted) { + onAbort(); + return; + } try { signal.addEventListener('abort', onAbort); - if (signal.aborted) { - onAbort(); - return; - } + abortListenerAdded = true; await this.config.applySecurityAuthentication(ctx); if (signal.aborted) { + // If aborted during authentication, onAbort already dispatched done callback. return; } @@ -112,7 +116,9 @@ export class Watch { } catch (err) { doneCallOnce(err); } finally { - signal.removeEventListener('abort', onAbort); + if (abortListenerAdded) { + signal.removeEventListener('abort', onAbort); + } } }; startWatch().catch(doneCallOnce); diff --git a/src/watch_test.ts b/src/watch_test.ts index ac70ef7b0d8..6f562a09060 100644 --- a/src/watch_test.ts +++ b/src/watch_test.ts @@ -432,8 +432,10 @@ describe('Watch', () => { }); it('should abort before fetch starts when controller is aborted early', async (t) => { + const AUTH_DELAY_MS = 50; + const ASSERT_NO_REQUEST_DELAY_MS = 75; let requestReceived = false; - const kc = await setupMockSystem(t, (_req: any, _res: any) => { + const kc = await setupMockSystem(t, () => { requestReceived = true; }); const watch = new Watch(kc); @@ -442,7 +444,7 @@ describe('Watch', () => { ); watch.config.applySecurityAuthentication = async (ctx: any) => { await new Promise((resolve) => { - setTimeout(resolve, 50); + setTimeout(resolve, AUTH_DELAY_MS); }); await originalApplySecurityAuthentication(ctx); }; @@ -468,7 +470,8 @@ describe('Watch', () => { controller.abort(); await donePromise; await new Promise((resolve) => { - setTimeout(resolve, 75); + // Wait longer than AUTH_DELAY_MS to ensure any incorrect fetch startup would have occurred. + setTimeout(resolve, ASSERT_NO_REQUEST_DELAY_MS); }); strictEqual(doneErr?.name, 'AbortError'); strictEqual(requestReceived, false);