diff --git a/packages/core/src/infrastructure/http/request.ts b/packages/core/src/infrastructure/http/request.ts index d5e0430b0..91691e561 100644 --- a/packages/core/src/infrastructure/http/request.ts +++ b/packages/core/src/infrastructure/http/request.ts @@ -65,9 +65,14 @@ const getTrustMaterialAsync = async () => { .digest("hex"); if (fingerprint !== cachedTrustFingerprint) { - // Superseded agents hold their idle sockets open until they are closed. + // undici's close() waits for enqueued requests to finish, so releasing the pool cannot interrupt + // a request already in flight. + // + // The node agents are deliberately *not* destroyed: agent.destroy() destroys sockets that are + // currently in use, so an admin adding a certificate could abort an in-flight calendar sync. + // They run without keepAlive, so dropping the reference is enough - each socket closes once its + // own response completes. for (const agent of undiciAgentCache.values()) void agent.close(); - for (const agent of httpsAgentCache.values()) agent.destroy(); undiciAgentCache.clear(); httpsAgentCache.clear(); cachedTrustFingerprint = fingerprint; diff --git a/packages/core/src/test/infrastructure/http/request.spec.ts b/packages/core/src/test/infrastructure/http/request.spec.ts index fbf3c5dcd..df9e86aa0 100644 --- a/packages/core/src/test/infrastructure/http/request.spec.ts +++ b/packages/core/src/test/infrastructure/http/request.spec.ts @@ -138,6 +138,27 @@ describe("createHttpsAgentAsync", () => { expect(after).not.toBe(before); }); + test("should not destroy a superseded agent, which would abort requests in flight", async () => { + // agent.destroy() destroys sockets that are currently in use, so destroying the old agent when an + // admin adds a certificate could abort an in-flight calendar sync. These agents run without + // keepAlive, so dropping the reference is enough. + const { createHttpsAgentAsync } = await importRequestModuleAsync(); + const before = await createHttpsAgentAsync(); + // keepAlive is a real runtime property but is absent from @types/node's public Agent surface. + // It is asserted because it is the premise of not destroying: without keepAlive, a dropped agent's + // sockets close on their own once their response completes. + expect((before as unknown as { keepAlive: boolean }).keepAlive).toBe(false); + const destroySpy = vi.spyOn(before, "destroy"); + + // Act + trustMaterial.certificates = [{ fileName: "added.crt", content: "-----BEGIN CERTIFICATE-----" }]; + const after = await createHttpsAgentAsync(); + + // Assert + expect(after).not.toBe(before); + expect(destroySpy).not.toHaveBeenCalled(); + }); + test("should give a caller supplying an override a dedicated agent", async () => { // Arrange const { createHttpsAgentAsync } = await importRequestModuleAsync();