Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions scripts/lib/orchestrator-gateway.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,11 @@ export async function verifyOrchestratorHealthz(healthzUrl, options = {}) {
/^\d+$/u.test(advertisedLength.trim()) &&
Number(advertisedLength) > HEALTH_BODY_LIMIT_BYTES
) {
await response.body?.cancel?.().catch(() => undefined);
try {
void response.body?.cancel?.().catch(() => undefined);
} catch {
// Cancellation is cleanup only after the size decision is final.
}
throw new Error("contextual-orchestrator health response is too large");
}

Expand All @@ -367,9 +371,9 @@ export async function verifyOrchestratorHealthz(healthzUrl, options = {}) {
} finally {
if (!completed) {
try {
await reader.cancel();
void reader.cancel().catch(() => undefined);
} catch {
// Cancellation is cleanup only; the primary bounded-read failure wins.
// Cleanup must not replace the primary bounded-read failure.
}
}
reader.releaseLock();
Expand Down
130 changes: 130 additions & 0 deletions test/orchestrator-gateway-stream-bound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@ import { describe, expect, it, vi } from "vitest";

import { verifyOrchestratorHealthz } from "../scripts/lib/orchestrator-gateway.mjs";

/**
* Bound hostile cleanup promises so cancellation-liveness regressions fail deterministically.
*
* @param promise Operation whose completion must not depend on cleanup.
* @param timeoutMs Failsafe interval for the hostile test.
* @returns Operation result or the sentinel proving it exceeded the test bound.
*/
async function settleWithin<T>(promise: Promise<T>, timeoutMs = 100): Promise<T | "failsafe"> {
return Promise.race([
promise,
new Promise<"failsafe">((resolve) => {
setTimeout(() => resolve("failsafe"), timeoutMs);
}),
]);
}

describe("contextual-orchestrator streamed health response", () => {
it("stops a chunked response at the byte ceiling without arrayBuffer materialization", async () => {
let readCount = 0;
Expand Down Expand Up @@ -49,6 +65,120 @@ describe("contextual-orchestrator streamed health response", () => {
expect(arrayBufferCalled).toBe(false);
});

it("does not let stalled reader cancellation delay an already-decided oversize rejection", async () => {
let cancellationStarted = false;
let released = false;
const reader = {
async read() {
return { done: false, value: new Uint8Array(65_537) };
},
cancel() {
cancellationStarted = true;
return new Promise<void>(() => {});
},
releaseLock() {
released = true;
},
};
const response = {
ok: true,
status: 200,
headers: { get: () => null },
body: { getReader: () => reader },
} as unknown as Response;

const outcome = await settleWithin(
verifyOrchestratorHealthz("https://orchestrator.example/healthz", {
fetchImpl: (async () => response) as typeof fetch,
}).then(
() => "resolved" as const,
(error: unknown) => error,
),
);

expect(outcome).not.toBe("failsafe");
expect(outcome).toBeInstanceOf(Error);
expect((outcome as Error).message).toMatch(/health response is too large/);
expect(cancellationStarted).toBe(true);
expect(released).toBe(true);
});

it("keeps the oversize failure and releases the reader when cancellation throws synchronously", async () => {
let released = false;
const reader = {
async read() {
return { done: false, value: new Uint8Array(65_537) };
},
cancel() {
throw new Error("cleanup transport failed");
},
releaseLock() {
released = true;
},
};
const response = {
ok: true,
status: 200,
headers: { get: () => null },
body: { getReader: () => reader },
} as unknown as Response;

await expect(
verifyOrchestratorHealthz("https://orchestrator.example/healthz", {
fetchImpl: (async () => response) as typeof fetch,
}),
).rejects.toThrow(/health response is too large/);
expect(released).toBe(true);
});

it("does not let stalled response-body cancellation delay content-length rejection", async () => {
let cancellationStarted = false;
const response = {
ok: true,
status: 200,
headers: { get: () => "65537" },
body: {
cancel() {
cancellationStarted = true;
return new Promise<void>(() => {});
},
},
} as unknown as Response;

const outcome = await settleWithin(
verifyOrchestratorHealthz("https://orchestrator.example/healthz", {
fetchImpl: (async () => response) as typeof fetch,
}).then(
() => "resolved" as const,
(error: unknown) => error,
),
);

expect(outcome).not.toBe("failsafe");
expect(outcome).toBeInstanceOf(Error);
expect((outcome as Error).message).toMatch(/health response is too large/);
expect(cancellationStarted).toBe(true);
});

it("keeps the content-length oversize failure when response cancellation throws synchronously", async () => {
const response = {
ok: true,
status: 200,
headers: { get: () => "65537" },
body: {
cancel() {
throw new Error("cleanup transport failed");
},
},
} as unknown as Response;

await expect(
verifyOrchestratorHealthz("https://orchestrator.example/healthz", {
fetchImpl: (async () => response) as typeof fetch,
}),
).rejects.toThrow(/health response is too large/);
});

it("does not retain fragmented chunks for a second concatenation allocation", async () => {
const payload = new TextEncoder().encode(
JSON.stringify({ status: "ok", service: "contextual-orchestrator" }),
Expand Down
Loading