From 69d0f4972f5f65d2505b80bd1a92f4ae377fcdc6 Mon Sep 17 00:00:00 2001 From: Gandalf the Greybeard Date: Thu, 23 Apr 2026 16:43:32 +0000 Subject: [PATCH] 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 --- src/server/execute.test.ts | 89 +++++++++++++++++++++++++++++++++++++- src/server/execute.ts | 2 +- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/server/execute.test.ts b/src/server/execute.test.ts index e4d7614..1b4b30a 100644 --- a/src/server/execute.test.ts +++ b/src/server/execute.test.ts @@ -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(); + }); +}); diff --git a/src/server/execute.ts b/src/server/execute.ts index da1df61..87916c4 100644 --- a/src/server/execute.ts +++ b/src/server/execute.ts @@ -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"],