test: regression for streamPodLogsOnce bail timer (FAR-10)
Uses vi.mock on k8s-client and vi.useFakeTimers to prove that when logApi.log() never resolves (the FAR-10 hang shape) and stopSignal fires, streamPodLogsOnce still returns within the bail window (LOG_STREAM_BAIL_TIMEOUT_MS). Exports streamPodLogsOnce so the test can call it directly. Also covers the no-stopSignal happy path. 269/269 passing (+2 new). Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,6 +1,21 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import type * as k8s from "@kubernetes/client-node";
|
import type * as k8s from "@kubernetes/client-node";
|
||||||
import { isK8s404, buildPartialRunError, isReattachableOrphan, describePodTerminatedError } from "./execute.js";
|
import type { Writable } from "node:stream";
|
||||||
|
|
||||||
|
// Mock the K8s client before importing execute so streamPodLogsOnce picks up
|
||||||
|
// the mocked getLogApi. The mock's logApi.log never resolves, simulating the
|
||||||
|
// FAR-10 hang: K8s API drops the connection but the client awaits forever.
|
||||||
|
const mockLogFn = vi.fn();
|
||||||
|
vi.mock("./k8s-client.js", () => ({
|
||||||
|
getLogApi: () => ({ log: mockLogFn }),
|
||||||
|
getBatchApi: () => ({}),
|
||||||
|
getCoreApi: () => ({}),
|
||||||
|
getAuthzApi: () => ({}),
|
||||||
|
getSelfPodInfo: vi.fn(),
|
||||||
|
resetCache: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { isK8s404, buildPartialRunError, isReattachableOrphan, describePodTerminatedError, streamPodLogsOnce } = await import("./execute.js");
|
||||||
|
|
||||||
function makeJob(opts: {
|
function makeJob(opts: {
|
||||||
runId?: string;
|
runId?: string;
|
||||||
@@ -245,3 +260,73 @@ describe("describePodTerminatedError", () => {
|
|||||||
expect(msg).toContain("Error");
|
expect(msg).toContain("Error");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: FAR-10 hardening — streamPodLogsOnce must not hang forever when
|
||||||
|
// the K8s client's logApi.log call never resolves. When stopSignal fires, the
|
||||||
|
// bail timer must force-return within LOG_STREAM_BAIL_TIMEOUT_MS (3s in the
|
||||||
|
// implementation) so execute() does not get stuck waiting for a dead stream.
|
||||||
|
describe("streamPodLogsOnce bail timer", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockLogFn.mockReset();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns within the bail window when stopSignal fires during a hung log call", async () => {
|
||||||
|
// logApi.log never resolves — simulates the FAR-10 hang where the K8s
|
||||||
|
// response stream stalls without closing the connection.
|
||||||
|
mockLogFn.mockImplementation((_ns, _pod, _ctr, _writable: Writable) => {
|
||||||
|
return new Promise(() => { /* never resolves */ });
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopSignal = { stopped: false };
|
||||||
|
const onLog = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const resultPromise = streamPodLogsOnce(
|
||||||
|
"default",
|
||||||
|
"mypod",
|
||||||
|
onLog,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
stopSignal,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fire stopSignal; let the 200ms poller tick and start the bail timer.
|
||||||
|
stopSignal.stopped = true;
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
// Advance past the 3s bail timeout. streamPodLogsOnce must now resolve
|
||||||
|
// with an empty string (no chunks were captured) rather than hanging.
|
||||||
|
await vi.advanceTimersByTimeAsync(3_100);
|
||||||
|
|
||||||
|
const result = await resultPromise;
|
||||||
|
expect(result).toBe("");
|
||||||
|
expect(mockLogFn).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns promptly if logApi.log resolves before stopSignal fires (happy path, no bail involved)", async () => {
|
||||||
|
mockLogFn.mockImplementation(async (_ns, _pod, _ctr, _writable: Writable) => {
|
||||||
|
// Resolve immediately — normal log-stream completion.
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onLog = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// No stopSignal → no bail machinery engaged.
|
||||||
|
const result = await streamPodLogsOnce(
|
||||||
|
"default",
|
||||||
|
"mypod",
|
||||||
|
onLog,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("");
|
||||||
|
expect(mockLogFn).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ async function waitForPod(
|
|||||||
* Stream pod logs once via follow. Returns accumulated stdout when the
|
* Stream pod logs once via follow. Returns accumulated stdout when the
|
||||||
* stream ends (container exit, API disconnect, or abort signal).
|
* stream ends (container exit, API disconnect, or abort signal).
|
||||||
*/
|
*/
|
||||||
async function streamPodLogsOnce(
|
export async function streamPodLogsOnce(
|
||||||
namespace: string,
|
namespace: string,
|
||||||
podName: string,
|
podName: string,
|
||||||
onLog: AdapterExecutionContext["onLog"],
|
onLog: AdapterExecutionContext["onLog"],
|
||||||
|
|||||||
Reference in New Issue
Block a user