From c8525b956524029e920d0919ebc51e2cf75c48e5 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Tue, 11 Aug 2026 09:23:20 +0900 Subject: [PATCH] fix(images): restore pinned download connect deadline --- src/images/artifacts.ts | 5 ++++ tests/images/pinned-https-get.test.ts | 33 ++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts index 71e53374f..9515cece3 100644 --- a/src/images/artifacts.ts +++ b/src/images/artifacts.ts @@ -12,6 +12,8 @@ const MAX_DECODED_BYTES_PER_IMAGE = 50 * 1024 * 1024; const MAX_DECODED_BYTES_PER_RESPONSE = 100 * 1024 * 1024; /** Hard cap for remote image downloads (also enforced inside pinnedHttpsGet). */ export const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MiB +/** Deadline for establishing TCP and TLS for provider-returned artifact URLs. */ +export const DOWNLOAD_CONNECT_TIMEOUT_MS = 10_000; /** Idle timeout for pinned HTTPS connect/headers/body when no AbortSignal is provided. */ export const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000; @@ -270,6 +272,7 @@ export function pinnedHttpsGet( signal?: AbortSignal, options?: { maxBytes?: number; + connectTimeoutMs?: number; idleTimeoutMs?: number; rejectUnauthorized?: boolean; }, @@ -279,9 +282,11 @@ export function pinnedHttpsGet( throw new Error(`image URL must use HTTPS, got ${parsed.protocol}`); } const maxBytes = options?.maxBytes ?? MAX_DOWNLOAD_BYTES; + const connectTimeoutMs = options?.connectTimeoutMs ?? DOWNLOAD_CONNECT_TIMEOUT_MS; const idleTimeoutMs = options?.idleTimeoutMs ?? DOWNLOAD_IDLE_TIMEOUT_MS; return pinnedHttpGet(url, pinned, signal, { maxBytes, + connectTimeoutMs, idleTimeoutMs, rejectUnauthorized: options?.rejectUnauthorized, context: "image download", diff --git a/tests/images/pinned-https-get.test.ts b/tests/images/pinned-https-get.test.ts index e3d1f8017..a6e1fba36 100644 --- a/tests/images/pinned-https-get.test.ts +++ b/tests/images/pinned-https-get.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from "node:events"; -import { describe, expect, mock, test } from "bun:test"; +import { describe, expect, mock, spyOn, test } from "bun:test"; type LookupCb = | ((err: Error | null, address: string, family: number) => void) @@ -54,6 +54,37 @@ function installHttpsMock(bodyChunks: Buffer[], statusCode = 200) { } describe("pinnedHttpsGet transport", () => { + test("applies a connect deadline by default", async () => { + const requestMock = mock(() => { + const req = new EventEmitter() as EventEmitter & { + setTimeout: Function; + end: Function; + destroy: Function; + }; + req.setTimeout = mock(() => {}); + req.end = mock(() => {}); + req.destroy = mock(() => {}); + queueMicrotask(() => req.emit("socket", Object.assign(new EventEmitter(), { connecting: true }))); + return req; + }); + mock.module("node:https", () => ({ default: { request: requestMock }, request: requestMock })); + const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void, ms?: number) => { + expect(ms).toBe(10_000); + queueMicrotask(callback); + return 1 as unknown as ReturnType; + }) as typeof setTimeout); + + try { + const { pinnedHttpsGet } = await import("../../src/images/artifacts"); + await expect(pinnedHttpsGet( + "https://cdn.example/hang.png", + { address: "93.184.216.34", family: 4 }, + )).rejects.toThrow(/connect timed out/); + } finally { + timeoutSpy.mockRestore(); + } + }); + test("lookup honors scalar and { all: true } callback shapes", async () => { let capturedLookup: ((hostname: string, opts: unknown, cb?: LookupCb) => void) | undefined; const requestMock = mock((options: { lookup?: typeof capturedLookup }, onResponse?: Function) => {