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:
Gandalf the Greybeard
2026-04-23 16:43:32 +00:00
parent c7706d742f
commit 69d0f4972f
2 changed files with 88 additions and 3 deletions
+87 -2
View File
@@ -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 { 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: {
runId?: string;
@@ -245,3 +260,73 @@ describe("describePodTerminatedError", () => {
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();
});
});
+1 -1
View File
@@ -248,7 +248,7 @@ async function waitForPod(
* Stream pod logs once via follow. Returns accumulated stdout when the
* stream ends (container exit, API disconnect, or abort signal).
*/
async function streamPodLogsOnce(
export async function streamPodLogsOnce(
namespace: string,
podName: string,
onLog: AdapterExecutionContext["onLog"],