diff --git a/src/plugin-sdk/qa-runtime.test.ts b/src/plugin-sdk/qa-runtime.test.ts index 5b6d4694b6e3..02af7c5028d8 100644 --- a/src/plugin-sdk/qa-runtime.test.ts +++ b/src/plugin-sdk/qa-runtime.test.ts @@ -1,8 +1,8 @@ +import { createServer } from "node:net"; /** * Tests QA runtime command loading and private CLI gating. */ import { Command } from "commander"; -import { createServer } from "node:net"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanupTempDirs, @@ -60,6 +60,21 @@ describe("plugin-sdk qa-runtime", () => { }; } + function cancelTrackedFetchResponse(ok = true) { + let canceled = false; + return { + response: { + ok, + body: { + cancel: vi.fn(async () => { + canceled = true; + }), + }, + }, + wasCanceled: () => canceled, + }; + } + it("stays cold until the runtime seam is used", async () => { const module = await import("./qa-runtime.js"); @@ -341,6 +356,52 @@ describe("plugin-sdk qa-runtime", () => { expect(fetchImpl).toHaveBeenCalledWith("http://172.18.0.4:18789/healthz"); }); + it("cancels compose service health probe response bodies", async () => { + const module = await import("./qa-runtime.js"); + const runtime = module.createQaDockerRuntime({ auditContext: "qa-test" }); + const runCommand = vi.fn(async (_command: string, args: string[]) => { + if (args.includes("ps")) { + return { stdout: "qa-gateway-one\n", stderr: "" }; + } + return { stdout: "172.18.0.4\n", stderr: "" }; + }); + const probe = cancelTrackedFetchResponse(true); + const fetchImpl = vi.fn(async () => probe.response); + + await expect( + runtime.resolveComposeServiceUrl( + "gateway", + 18789, + "/tmp/docker-compose.yml", + "/repo", + runCommand, + fetchImpl, + ), + ).resolves.toBe("http://172.18.0.4:18789/"); + expect(probe.wasCanceled()).toBe(true); + }); + + it("cancels waitForHealth response bodies after each probe", async () => { + const module = await import("./qa-runtime.js"); + const runtime = module.createQaDockerRuntime({ auditContext: "qa-test" }); + const first = cancelTrackedFetchResponse(false); + const second = cancelTrackedFetchResponse(true); + const responses = [first.response, second.response]; + const fetchImpl = vi.fn(async () => responses.shift() ?? second.response); + const sleepImpl = vi.fn(async () => {}); + + await runtime.waitForHealth("http://127.0.0.1:18789/healthz", { + fetchImpl, + sleepImpl, + timeoutMs: 1000, + pollMs: 1, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(first.wasCanceled()).toBe(true); + expect(second.wasCanceled()).toBe(true); + }); + it("resolves an unpinned QA Docker host port away from an occupied loopback default", async () => { const module = await import("./qa-runtime.js"); const reservation = await occupyLoopbackPort(); diff --git a/src/plugin-sdk/qa-runtime.ts b/src/plugin-sdk/qa-runtime.ts index f1195e817bdc..6d75c9e24bdb 100644 --- a/src/plugin-sdk/qa-runtime.ts +++ b/src/plugin-sdk/qa-runtime.ts @@ -249,7 +249,11 @@ export type QaDockerRunCommand = ( ) => Promise<{ stdout: string; stderr: string }>; /** Minimal fetch-like health probe used by QA Docker runtime helpers. */ -export type QaDockerFetchLike = (input: string) => Promise<{ ok: boolean }>; +export type QaDockerFetchResponse = { + ok: boolean; + body?: { cancel?: () => unknown } | null; +}; +export type QaDockerFetchLike = (input: string) => Promise; const DEFAULT_QA_DOCKER_COMMAND_TIMEOUT_MS = 120_000; @@ -484,14 +488,23 @@ function parseDockerComposePsRows(stdout: string) { } async function isQaDockerHealthy(url: string, fetchImpl: QaDockerFetchLike) { + let response: QaDockerFetchResponse | undefined; try { - const response = await fetchImpl(url); + response = await fetchImpl(url); return response.ok; } catch { return false; + } finally { + await releaseQaDockerFetchResponse(response); } } +async function releaseQaDockerFetchResponse(response: QaDockerFetchResponse | undefined) { + try { + await response?.body?.cancel?.(); + } catch {} +} + /** Create Docker command, health-check, and compose helpers for QA harnesses. */ export function createQaDockerRuntime(params: { auditContext: string; @@ -548,14 +561,17 @@ export function createQaDockerRuntime(params: { let lastError: unknown = null; while (Date.now() < deadline) { + let response: QaDockerFetchResponse | undefined; try { - const response = await deps.fetchImpl(url); + response = await deps.fetchImpl(url); if (response.ok) { return; } lastError = new Error(`Health check returned non-OK for ${url}`); } catch (error) { lastError = error; + } finally { + await releaseQaDockerFetchResponse(response); } await deps.sleepImpl(pollMs); }