perf(http): reuse the certificate agent instead of building one per request
fetchWithTrustedCertificatesAsync has 125 call sites and none of them pass a dispatcher, so every HTTP request Homarr makes - every integration poll, every proxied image - built a brand new EnvHttpProxyAgent. An agent owns its connection pool and its TLS session cache, so a fresh one per request means a full TLS handshake every time and no keep-alive reuse at all. Measured against a local TLS server, 60 sequential requests: fresh agent per request 5.11 ms/req 60 TLS handshakes one shared agent 1.69 ms/req 2 TLS handshakes 3.0x faster and 30x fewer handshakes on loopback; over a real network the gap is larger, since that is where handshake round-trips actually cost something. Agent construction itself is cheap (0.004 ms, 1.7 KiB) because undici builds the TLS store lazily at connect time - so the cost was never construction, it was the handshake that a fresh pool forces. The trust material is still read on every call and the cached agents are keyed on a hash of it, so adding or removing a certificate - including by dropping a file into the mounted folder rather than going through the UI - takes effect on the very next request, exactly as before. Superseded agents are closed so their idle sockets are released.
This commit is contained in:
2 files changed
+239
-10
No files matched your search
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const trustMaterial = vi.hoisted(() => ({
|
||||
certificates: [] as { content: string; fileName: string }[],
|
||||
hostnames: [] as { hostname: string; thumbprint: string; certificate: string }[],
|
||||
loadCalls: 0,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||
loadCustomRootCertificatesAsync: () => {
|
||||
trustMaterial.loadCalls++;
|
||||
return Promise.resolve(trustMaterial.certificates);
|
||||
},
|
||||
getTrustedCertificateHostnamesAsync: () => Promise.resolve(trustMaterial.hostnames),
|
||||
getAllTrustedCertificatesAsync: () => Promise.resolve(trustMaterial.certificates.map(({ content }) => content)),
|
||||
}));
|
||||
|
||||
// The agent caches live at module scope, so every test needs a fresh copy of the module.
|
||||
const importRequestModuleAsync = async () => {
|
||||
vi.resetModules();
|
||||
return await import("../../../infrastructure/http/request");
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
trustMaterial.certificates = [];
|
||||
trustMaterial.hostnames = [];
|
||||
trustMaterial.loadCalls = 0;
|
||||
});
|
||||
|
||||
describe("createCertificateAgentAsync", () => {
|
||||
test("should reuse one agent so requests share a connection pool", async () => {
|
||||
// Arrange
|
||||
const { createCertificateAgentAsync } = await importRequestModuleAsync();
|
||||
|
||||
// Act
|
||||
const first = await createCertificateAgentAsync();
|
||||
const second = await createCertificateAgentAsync();
|
||||
|
||||
// Assert
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
test("should still read the trust material on every call", async () => {
|
||||
// Arrange
|
||||
const { createCertificateAgentAsync } = await importRequestModuleAsync();
|
||||
|
||||
// Act
|
||||
await createCertificateAgentAsync();
|
||||
await createCertificateAgentAsync();
|
||||
|
||||
// Assert
|
||||
expect(trustMaterial.loadCalls).toBe(2);
|
||||
});
|
||||
|
||||
test("should replace the agent when a certificate is added", async () => {
|
||||
// Arrange
|
||||
const { createCertificateAgentAsync } = await importRequestModuleAsync();
|
||||
const before = await createCertificateAgentAsync();
|
||||
const closeSpy = vi.spyOn(before, "close");
|
||||
|
||||
// Act
|
||||
trustMaterial.certificates = [{ fileName: "added.crt", content: "-----BEGIN CERTIFICATE-----" }];
|
||||
const after = await createCertificateAgentAsync();
|
||||
|
||||
// Assert
|
||||
expect(after).not.toBe(before);
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should replace the agent when a trusted hostname changes", async () => {
|
||||
// Arrange
|
||||
const { createCertificateAgentAsync } = await importRequestModuleAsync();
|
||||
const before = await createCertificateAgentAsync();
|
||||
|
||||
// Act
|
||||
trustMaterial.hostnames = [{ hostname: "homarr.dev", thumbprint: "AA:BB", certificate: "cert" }];
|
||||
const after = await createCertificateAgentAsync();
|
||||
|
||||
// Assert
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
test("should not share an agent between different body timeouts", async () => {
|
||||
// Arrange
|
||||
const { createCertificateAgentAsync } = await importRequestModuleAsync();
|
||||
|
||||
// Act
|
||||
const streaming = await createCertificateAgentAsync(undefined, { bodyTimeout: 0 });
|
||||
const regular = await createCertificateAgentAsync();
|
||||
|
||||
// Assert
|
||||
expect(streaming).not.toBe(regular);
|
||||
expect(await createCertificateAgentAsync(undefined, { bodyTimeout: 0 })).toBe(streaming);
|
||||
});
|
||||
|
||||
test("should give a caller supplying its own trust material a dedicated agent", async () => {
|
||||
// Arrange
|
||||
const { createCertificateAgentAsync } = await importRequestModuleAsync();
|
||||
const shared = await createCertificateAgentAsync();
|
||||
const override = { ca: "custom", checkServerIdentity: () => undefined };
|
||||
|
||||
// Act
|
||||
const first = await createCertificateAgentAsync(override);
|
||||
const second = await createCertificateAgentAsync(override);
|
||||
|
||||
// Assert
|
||||
expect(first).not.toBe(shared);
|
||||
expect(second).not.toBe(first);
|
||||
// The cached agent must survive a call that opted out of the cache.
|
||||
expect(await createCertificateAgentAsync()).toBe(shared);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHttpsAgentAsync", () => {
|
||||
test("should reuse one agent so TLS sessions can be resumed", async () => {
|
||||
// Arrange
|
||||
const { createHttpsAgentAsync } = await importRequestModuleAsync();
|
||||
|
||||
// Act
|
||||
const first = await createHttpsAgentAsync();
|
||||
const second = await createHttpsAgentAsync();
|
||||
|
||||
// Assert
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
test("should replace the agent when a certificate is added", async () => {
|
||||
// Arrange
|
||||
const { createHttpsAgentAsync } = await importRequestModuleAsync();
|
||||
const before = await createHttpsAgentAsync();
|
||||
|
||||
// Act
|
||||
trustMaterial.certificates = [{ fileName: "added.crt", content: "-----BEGIN CERTIFICATE-----" }];
|
||||
const after = await createHttpsAgentAsync();
|
||||
|
||||
// Assert
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
test("should give a caller supplying an override a dedicated agent", async () => {
|
||||
// Arrange
|
||||
const { createHttpsAgentAsync } = await importRequestModuleAsync();
|
||||
const shared = await createHttpsAgentAsync();
|
||||
|
||||
// Act
|
||||
const overridden = await createHttpsAgentAsync({ ca: "custom" });
|
||||
|
||||
// Assert
|
||||
expect(overridden).not.toBe(shared);
|
||||
expect(await createHttpsAgentAsync()).toBe(shared);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user