Skip to content

Commit dbb11a9

Browse files
committed
fix external signal listener leak
1 parent 472b40c commit dbb11a9

File tree

3 files changed

+130
-11
lines changed

3 files changed

+130
-11
lines changed

packages/ai/src/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const PACKAGE_VERSION = version;
3232

3333
export const LANGUAGE_TAG = 'gl-js';
3434

35-
export const DEFAULT_FETCH_TIMEOUT_MS = 180 * 1000;
35+
export const DEFAULT_FETCH_TIMEOUT_MS = 180 * 1000; // TODO: Extend default timeout to accommodate for longer generation requests with pro models.
3636

3737
/**
3838
* Defines the name of the default in-cloud model to use for hybrid inference.

packages/ai/src/requests/request.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,5 +706,124 @@ describe('request methods', () => {
706706
expect(fetchOptions.signal).to.be.instanceOf(AbortSignal);
707707
expect(fetchOptions.signal?.aborted).to.be.false;
708708
});
709+
710+
it('should remove abort listener on successful completion to prevent memory leaks', async () => {
711+
const controller = new AbortController();
712+
const addSpy = Sinon.spy(controller.signal, 'addEventListener');
713+
const removeSpy = Sinon.spy(controller.signal, 'removeEventListener');
714+
715+
const mockResponse = new Response('{}', {
716+
status: 200,
717+
statusText: 'OK'
718+
});
719+
fetchStub.resolves(mockResponse);
720+
721+
await makeRequest(
722+
{
723+
model: 'models/model-name',
724+
task: Task.GENERATE_CONTENT,
725+
apiSettings: fakeApiSettings,
726+
stream: false,
727+
singleRequestOptions: { signal: controller.signal }
728+
},
729+
'{}'
730+
);
731+
732+
expect(addSpy).to.have.been.calledOnceWith('abort');
733+
expect(removeSpy).to.have.been.calledOnceWith('abort');
734+
});
735+
736+
it('should remove listener if fetch itself rejects', async () => {
737+
const controller = new AbortController();
738+
const removeSpy = Sinon.spy(controller.signal, 'removeEventListener');
739+
const error = new Error('Network failure');
740+
fetchStub.rejects(error);
741+
742+
const requestPromise = makeRequest(
743+
{
744+
model: 'models/model-name',
745+
task: Task.GENERATE_CONTENT,
746+
apiSettings: fakeApiSettings,
747+
stream: false,
748+
singleRequestOptions: { signal: controller.signal }
749+
},
750+
'{}'
751+
);
752+
753+
await expect(requestPromise).to.be.rejectedWith(AIError, /Network failure/);
754+
expect(removeSpy).to.have.been.calledOnce;
755+
});
756+
757+
it('should remove listener if response is not ok', async () => {
758+
const controller = new AbortController();
759+
const removeSpy = Sinon.spy(controller.signal, 'removeEventListener');
760+
const mockResponse = new Response('{}', {
761+
status: 500,
762+
statusText: 'Internal Server Error'
763+
});
764+
fetchStub.resolves(mockResponse);
765+
766+
const requestPromise = makeRequest(
767+
{
768+
model: 'models/model-name',
769+
task: Task.GENERATE_CONTENT,
770+
apiSettings: fakeApiSettings,
771+
stream: false,
772+
singleRequestOptions: { signal: controller.signal }
773+
},
774+
'{}'
775+
);
776+
777+
await expect(requestPromise).to.be.rejectedWith(AIError, /500/);
778+
expect(removeSpy).to.have.been.calledOnce;
779+
});
780+
781+
it('should abort immediately if timeout is 0', async () => {
782+
fetchStub.callsFake(fetchAborter);
783+
const requestPromise = makeRequest(
784+
{
785+
model: 'models/model-name',
786+
task: Task.GENERATE_CONTENT,
787+
apiSettings: fakeApiSettings,
788+
stream: false,
789+
singleRequestOptions: { timeout: 0 }
790+
},
791+
'{}'
792+
);
793+
794+
// Tick the clock just enough to trigger a timeout(0)
795+
await clock.tickAsync(1);
796+
797+
await expect(requestPromise).to.be.rejectedWith(
798+
AIError,
799+
/Timeout has expired/
800+
);
801+
});
802+
803+
it('should not error if signal is aborted after completion', async () => {
804+
const controller = new AbortController();
805+
const removeSpy = Sinon.spy(controller.signal, 'removeEventListener');
806+
const mockResponse = new Response('{}', {
807+
status: 200,
808+
statusText: 'OK'
809+
});
810+
fetchStub.resolves(mockResponse);
811+
812+
await makeRequest(
813+
{
814+
model: 'models/model-name',
815+
task: Task.GENERATE_CONTENT,
816+
apiSettings: fakeApiSettings,
817+
stream: false,
818+
singleRequestOptions: { signal: controller.signal }
819+
},
820+
'{}'
821+
);
822+
823+
// Listener should be removed, so this abort should do nothing.
824+
controller.abort('Too late');
825+
826+
expect(removeSpy).to.have.been.calledOnce;
827+
});
709828
});
710829
});

packages/ai/src/requests/request.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,12 @@ export async function makeRequest(
195195
);
196196
}, timeoutMillis);
197197

198+
const externalAbortListener = (): void => {
199+
logger.debug(`Aborting request to ${url} due to external abort signal.`);
200+
// If this listener was invoked, an external signal was aborted, so externalSignal must be defined.
201+
internalAbortController.abort(externalSignal!.reason);
202+
};
203+
198204
if (externalSignal) {
199205
if (externalSignal.aborted) {
200206
clearTimeout(fetchTimeoutId);
@@ -204,14 +210,7 @@ export async function makeRequest(
204210
);
205211
}
206212

207-
const externalAbortListener = (): void => {
208-
logger.debug(`Aborting request to ${url} due to external abort signal.`);
209-
internalAbortController.abort(externalSignal.reason);
210-
};
211-
212-
externalSignal.addEventListener('abort', externalAbortListener, {
213-
once: true
214-
});
213+
externalSignal.addEventListener('abort', externalAbortListener);
215214
}
216215

217216
try {
@@ -292,8 +291,9 @@ export async function makeRequest(
292291

293292
throw err;
294293
} finally {
295-
if (fetchTimeoutId) {
296-
clearTimeout(fetchTimeoutId);
294+
clearTimeout(fetchTimeoutId);
295+
if (externalSignal) {
296+
externalSignal.removeEventListener('abort', externalAbortListener);
297297
}
298298
}
299299
return response;

0 commit comments

Comments
 (0)