From d26e9423f4fdbe942bd73243cd7fcd6e11e6dcf1 Mon Sep 17 00:00:00 2001 From: Daniel Kennedy Date: Wed, 24 Sep 2025 14:05:45 -0400 Subject: [PATCH] Test: add a timeout test for downloading chunks from the stream --- .../__tests__/download-artifact.test.ts | 42 ++++++++++++++++++- .../internal/download/download-artifact.ts | 8 ++-- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/artifact/__tests__/download-artifact.test.ts b/packages/artifact/__tests__/download-artifact.test.ts index 9c7d7136..9f26880c 100644 --- a/packages/artifact/__tests__/download-artifact.test.ts +++ b/packages/artifact/__tests__/download-artifact.test.ts @@ -3,7 +3,7 @@ import * as http from 'http' import * as net from 'net' import * as path from 'path' import * as github from '@actions/github' -import {HttpClient} from '@actions/http-client' +import {HttpClient, HttpClientResponse} from '@actions/http-client' import type {RestEndpointMethods} from '@octokit/plugin-rest-endpoint-methods/dist-types/generated/method-types' import archiver from 'archiver' @@ -111,6 +111,16 @@ const mockGetArtifactSuccess = jest.fn(() => { } }) +const mockGetArtifactHung = jest.fn(() => { + const message = new http.IncomingMessage(new net.Socket()) + message.statusCode = 200 + // Don't push any data or call push(null) to end the stream + // This creates a stream that hangs and never completes + return { + message + } +}) + const mockGetArtifactFailure = jest.fn(() => { const message = new http.IncomingMessage(new net.Socket()) message.statusCode = 500 @@ -611,4 +621,34 @@ describe('download-artifact', () => { }) }) }) + + describe('streamExtractExternal', () => { + it('should fail if the timeout is exceeded', async () => { + + const mockSlowGetArtifact = jest + .fn(mockGetArtifactHung) + + const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( + () => { + return { + get: mockSlowGetArtifact + } + } + ) + + try { + await streamExtractExternal( + fixtures.blobStorageUrl, + fixtures.workspaceDir, + { timeout: 2 } + ) + expect(true).toBe(false) // should not be called + } catch (e: any) { + expect(e).toBeInstanceOf(Error) + expect(e.message).toContain('did not respond in 2ms') + expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString()) + expect(mockSlowGetArtifact).toHaveBeenCalledTimes(1) + } + }) + }) }) diff --git a/packages/artifact/src/internal/download/download-artifact.ts b/packages/artifact/src/internal/download/download-artifact.ts index 090260f9..40f8d71b 100644 --- a/packages/artifact/src/internal/download/download-artifact.ts +++ b/packages/artifact/src/internal/download/download-artifact.ts @@ -64,7 +64,8 @@ async function streamExtract( export async function streamExtractExternal( url: string, - directory: string + directory: string, + opts: { timeout: number } = { timeout: 30 * 1000 } ): Promise { const client = new httpClient.HttpClient(getUserAgentString()) const response = await client.get(url) @@ -74,18 +75,17 @@ export async function streamExtractExternal( ) } - const timeout = 30 * 1000 // 30 seconds let sha256Digest: string | undefined = undefined return new Promise((resolve, reject) => { const timerFn = (): void => { const timeoutError = new Error( - `Blob storage chunk did not respond in ${timeout}ms` + `Blob storage chunk did not respond in ${opts.timeout}ms` ) response.message.destroy(timeoutError) reject(timeoutError) } - const timer = setTimeout(timerFn, timeout) + const timer = setTimeout(timerFn, opts.timeout) const hashStream = crypto.createHash('sha256').setEncoding('hex') const passThrough = new stream.PassThrough()