fix(qa-lab): keep suite HTTP probes within deadlines (#108064)

* fix(qa-lab): bound suite HTTP probes

* style(qa-lab): format gateway timeout proof

* test(qa-lab): pin media deadline request count
This commit is contained in:
Alix-007
2026-07-15 22:45:30 -07:00
committed by GitHub
parent c924819292
commit 5a2bc73c28
6 changed files with 115 additions and 12 deletions
@@ -93,6 +93,9 @@ describe("qa suite runtime agent media helpers", () => {
timeoutMs: 2_000,
}),
).resolves.toBe("/tmp/generated.png");
expect(fetchJsonMock).toHaveBeenCalledOnce();
expect(fetchJsonMock).toHaveBeenCalledWith(expect.any(String), expect.any(Number));
expect(fetchJsonMock.mock.calls[0]?.[1]).toBeLessThanOrEqual(2_000);
});
it("falls back to generated image files under the gateway temp root", async () => {
@@ -86,12 +86,13 @@ async function resolveGeneratedImagePath(params: {
startedAtMs: number;
timeoutMs: number;
}) {
const startedAt = Date.now();
while (Date.now() - startedAt < params.timeoutMs) {
const deadline = Date.now() + params.timeoutMs;
while (Date.now() < deadline) {
if (params.env.mock) {
try {
const requests = await fetchJson<Array<{ allInputText?: string; toolOutput?: string }>>(
`${params.env.mock.baseUrl}/debug/requests`,
Math.max(1, deadline - Date.now()),
);
for (const request of requests.toReversed()) {
if (!(request.allInputText ?? "").includes(params.promptSnippet)) {
@@ -135,9 +136,12 @@ async function resolveGeneratedImagePath(params: {
if (match) {
return match;
}
await new Promise((resolve) => {
setTimeout(resolve, 250);
});
const remainingMs = deadline - Date.now();
if (remainingMs > 0) {
await new Promise((resolve) => {
setTimeout(resolve, Math.min(250, remainingMs));
});
}
}
throw new Error(`timed out after ${params.timeoutMs}ms`);
}
@@ -9,6 +9,7 @@ import {
patchConfig,
restartGatewayWithConfigPatch,
waitForConfigRestartSettle,
waitForGatewayHealthy,
} from "./suite-runtime-gateway.js";
import type { QaSuiteRuntimeEnv } from "./suite-runtime-types.js";
@@ -122,9 +123,63 @@ describe("qa suite gateway helpers", () => {
await expect(fetchJson("http://127.0.0.1:43123/config")).rejects.toThrow(
"qa-lab-suite-fetch-json: JSON response exceeds 16777216 bytes",
);
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({ timeoutMs: 15_000 }),
);
expect(release).toHaveBeenCalledTimes(1);
});
it("bounds stalled suite gateway JSON response bodies", async () => {
vi.useFakeTimers();
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockImplementation(async ({ timeoutMs }: { timeoutMs: number }) => {
let bodyController: ReadableStreamDefaultController<Uint8Array> | undefined;
const response = new Response(
new ReadableStream<Uint8Array>({
start(controller) {
bodyController = controller;
controller.enqueue(new TextEncoder().encode('{"pending":'));
},
}),
{ headers: { "content-type": "application/json" } },
);
setTimeout(() => bodyController?.error(new Error("request timed out")), timeoutMs);
return { response, release };
});
const request = fetchJson("http://127.0.0.1:43123/config", 1_000);
const rejection = expect(request).rejects.toThrow("request timed out");
await vi.advanceTimersByTimeAsync(1_000);
await rejection;
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({ timeoutMs: 1_000 }),
);
expect(release).toHaveBeenCalledTimes(1);
});
it("bounds a hung gateway health request by the remaining readiness deadline", async () => {
vi.useFakeTimers();
fetchWithSsrFGuardMock.mockImplementation(
async ({ timeoutMs }: { timeoutMs: number }) =>
await new Promise((_, reject) => {
setTimeout(() => reject(new Error("request timed out")), timeoutMs);
}),
);
const readiness = waitForGatewayHealthy(
{ gateway: { baseUrl: "http://127.0.0.1:43123" } } as never,
1_000,
);
const rejection = expect(readiness).rejects.toThrow("timed out after 1000ms");
await vi.advanceTimersByTimeAsync(1_000);
await rejection;
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({ timeoutMs: 1_000 }),
);
});
it("skips config mutations that would not change the snapshot", async () => {
const config = {
tools: {
+11 -4
View File
@@ -16,10 +16,13 @@ type QaGatewayMutationEnv = Pick<
"gateway" | "transport" | "providerMode" | "primaryModel" | "alternateModel"
>;
async function fetchJson<T>(url: string): Promise<T> {
const QA_SUITE_FETCH_JSON_TIMEOUT_MS = 15_000;
async function fetchJson<T>(url: string, timeoutMs = QA_SUITE_FETCH_JSON_TIMEOUT_MS): Promise<T> {
const { response, release } = await fetchWithSsrFGuard({
url,
policy: { allowPrivateNetwork: true },
timeoutMs,
auditContext: "qa-lab-suite-fetch-json",
});
try {
@@ -33,12 +36,13 @@ async function fetchJson<T>(url: string): Promise<T> {
}
async function waitForGatewayHealthy(env: Pick<QaSuiteRuntimeEnv, "gateway">, timeoutMs = 45_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const { response, release } = await fetchWithSsrFGuard({
url: `${env.gateway.baseUrl}/readyz`,
policy: { allowPrivateNetwork: true },
timeoutMs: Math.max(1, deadline - Date.now()),
auditContext: "qa-lab-suite-wait-for-gateway-healthy",
});
try {
@@ -51,7 +55,10 @@ async function waitForGatewayHealthy(env: Pick<QaSuiteRuntimeEnv, "gateway">, ti
} catch {
// retry
}
await sleep(250);
const remainingMs = deadline - Date.now();
if (remainingMs > 0) {
await sleep(Math.min(250, remainingMs));
}
}
throw new QaSuiteInfraError("gateway_ready_timeout", `timed out after ${timeoutMs}ms`);
}
+30
View File
@@ -213,6 +213,36 @@ describe("qa suite", () => {
expect(stop).toHaveBeenCalledTimes(1);
});
it("bounds a hung lab readiness request by the remaining startup deadline", async () => {
vi.useFakeTimers();
const stop = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockImplementation(
async ({ timeoutMs }: { timeoutMs: number }) =>
await new Promise((_, reject) => {
setTimeout(() => reject(new Error("request timed out")), timeoutMs);
}),
);
const readiness = qaSuiteProgressTesting.waitForQaLabReadyOrStopOwned({
lab: {
listenUrl: "http://127.0.0.1:43123",
stop,
},
ownsLab: true,
timeoutMs: 1_000,
});
const rejection = expect(readiness).rejects.toThrow(
"timed out after 1000ms waiting for qa-lab ready",
);
await vi.advanceTimersByTimeAsync(1_000);
await rejection;
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({ timeoutMs: 1_000 }),
);
expect(stop).toHaveBeenCalledTimes(1);
});
it("leaves caller-owned labs running when readiness never becomes healthy", async () => {
const stop = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
+7 -3
View File
@@ -264,12 +264,13 @@ function formatQaSuiteRunStartProgress(params: {
}
async function waitForQaLabReady(baseUrl: string, timeoutMs = 10_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const { response, release } = await fetchWithSsrFGuard({
url: `${baseUrl}/readyz`,
policy: { allowPrivateNetwork: true },
timeoutMs: Math.max(1, deadline - Date.now()),
auditContext: "qa-lab-suite-wait-for-lab-ready",
});
try {
@@ -282,7 +283,10 @@ async function waitForQaLabReady(baseUrl: string, timeoutMs = 10_000) {
} catch {
// retry
}
await sleep(100);
const remainingMs = deadline - Date.now();
if (remainingMs > 0) {
await sleep(Math.min(100, remainingMs));
}
}
throw new Error(`timed out after ${timeoutMs}ms waiting for qa-lab ready`);
}