Skip to content

Commit 86ab1cf

Browse files
committed
fix: Fix file download hang (#3411)
- split from #3325 after review (#3325 (comment))
1 parent 3a2c5fe commit 86ab1cf

2 files changed

Lines changed: 55 additions & 8 deletions

File tree

packages/http-crawler/src/internals/file-download.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import { Transform } from 'node:stream';
2-
import { finished } from 'node:stream/promises';
32

43
import type { BasicCrawlerOptions } from '@crawlee/basic';
54
import { BasicCrawler, ContextPipeline } from '@crawlee/basic';
65
import type { CrawlingContext, LoadedRequest, Request } from '@crawlee/core';
6+
import { ResponseWithUrl } from '@crawlee/http-client';
77
import type { Dictionary } from '@crawlee/types';
88

99
import type { ErrorHandler, GetUserDataFromRequest, InternalHttpHook, RequestHandler, RouterRoutes } from '../index.js';
1010
import { Router } from '../index.js';
1111
import { parseContentTypeFromResponse } from './utils.js';
1212

13+
const kBodyDrained = Symbol('bodyDrained');
14+
1315
export type FileDownloadErrorHandler<
1416
UserData extends Dictionary = any, // with default to Dictionary we cant use a typed router in untyped crawler
1517
> = ErrorHandler<FileDownloadCrawlingContext<UserData>>;
@@ -164,7 +166,13 @@ export class FileDownload extends BasicCrawler<FileDownloadCrawlingContext> {
164166
ContextPipeline.create<CrawlingContext>().compose({
165167
action: async (context) => this.initiateDownload(context),
166168
cleanup: async (context) => {
167-
await (context.response.body ? finished(context.response.body as any) : Promise.resolve());
169+
if (!context.response.bodyUsed) {
170+
// Nobody consumed the body — cancel it so the
171+
// underlying connection can be released.
172+
await context.response.body?.cancel();
173+
}
174+
175+
await (context as { [kBodyDrained]: Promise<void> })[kBodyDrained];
168176
},
169177
}),
170178
});
@@ -179,16 +187,43 @@ export class FileDownload extends BasicCrawler<FileDownloadCrawlingContext> {
179187

180188
context.request.url = response.url;
181189

190+
const { response: trackedResponse, bodyDrained } = trackBodyConsumption(response);
191+
182192
const contextExtension = {
183193
request: context.request as LoadedRequest<Request>,
184-
response,
194+
response: trackedResponse,
185195
contentType: { type, encoding },
196+
[kBodyDrained]: bodyDrained,
186197
};
187198

188199
return contextExtension;
189200
}
190201
}
191202

203+
/**
204+
* Wraps a Response so that we can track when the body stream has been fully
205+
* consumed (or errored). Pipes the original body through a TransformStream;
206+
* the readable side becomes the new Response body, and `pipeTo` gives us a
207+
* promise that resolves once the body is fully read or cancelled.
208+
*/
209+
function trackBodyConsumption(response: Response): { response: ResponseWithUrl; bodyDrained: Promise<void> } {
210+
if (!response.body) {
211+
return { response, bodyDrained: Promise.resolve() };
212+
}
213+
214+
const passthrough = new TransformStream();
215+
const bodyDrained = response.body.pipeTo(passthrough.writable).catch(() => {});
216+
217+
const trackedResponse = new ResponseWithUrl(passthrough.readable, {
218+
headers: response.headers,
219+
status: response.status,
220+
statusText: response.statusText,
221+
url: response.url,
222+
});
223+
224+
return { response: trackedResponse, bodyDrained };
225+
}
226+
192227
/**
193228
* Creates new {@apilink Router} instance that works based on request labels.
194229
* This instance can then serve as a `requestHandler` of your {@apilink FileDownload}.

test/core/crawlers/file_download.test.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ReadableStream } from 'node:stream/web';
66
import { setTimeout } from 'node:timers/promises';
77

88
import { FileDownload } from '@crawlee/http';
9+
import { FetchHttpClient } from '@crawlee/http-client';
910
import express from 'express';
1011
import { startExpressAppPromise } from 'test/shared/_helper.js';
1112
import { afterAll, beforeAll, expect, test } from 'vitest';
@@ -95,8 +96,9 @@ test('requestHandler - reading bytes synchronously', async () => {
9596

9697
const fileUrl = new URL('/file?size=1024&seed=123', url).toString();
9798

98-
await crawler.run([fileUrl]);
99+
const stats = await crawler.run([fileUrl]);
99100

101+
expect(stats.requestsFailed).toBe(0);
100102
expect(results).toHaveLength(1);
101103
expect(results[0].length).toBe(1024);
102104
expect(results[0]).toEqual(await ReadableStreamGenerator.getUint8Array(1024, 123));
@@ -116,25 +118,29 @@ test('requestHandler - streaming response body', async () => {
116118

117119
const fileUrl = new URL('/file?size=1024&seed=456', url).toString();
118120

119-
await crawler.run([fileUrl]);
121+
const stats = await crawler.run([fileUrl]);
120122

123+
expect(stats.requestsFailed).toBe(0);
121124
expect(result.length).toBe(1024);
122125
expect(result).toEqual(await ReadableStreamGenerator.getUint8Array(1024, 456));
123126
});
124127

125128
test('requestHandler receives response', async () => {
129+
const fileUrl = new URL('/file?size=1024&seed=321', url).toString();
130+
126131
const crawler = new FileDownload({
127132
maxRequestRetries: 0,
128133
requestHandler: async ({ response }) => {
129134
expect(response?.headers.get('content-type')).toBe('application/octet-stream');
130135
expect(response?.status).toBe(200);
131136
expect(response?.statusText).toBe('OK');
137+
expect(response?.url).toBe(fileUrl);
132138
},
133139
});
134140

135-
const fileUrl = new URL('/file?size=1024&seed=456', url).toString();
141+
const stats = await crawler.run([fileUrl]);
136142

137-
await crawler.run([fileUrl]);
143+
expect(stats.requestsFailed).toBe(0);
138144
});
139145

140146
test('crawler waits for the stream to be consumed', async () => {
@@ -146,8 +152,12 @@ test('crawler waits for the stream to be consumed', async () => {
146152
},
147153
});
148154

155+
// Use FetchHttpClient so response.body is a real streaming ReadableStream
156+
// (the default GotScrapingHttpClient buffers the entire response, making
157+
// the body complete instantly and the test a no-op).
149158
const crawler = new FileDownload({
150159
maxRequestRetries: 0,
160+
httpClient: new FetchHttpClient(),
151161
requestHandler: async ({ response }) => {
152162
pipelineWithCallbacks(response.body ?? ReadableStream.from([]), bufferingStream, (err) => {
153163
if (!err) {
@@ -162,7 +172,9 @@ test('crawler waits for the stream to be consumed', async () => {
162172

163173
// waits for a second after every kilobyte sent.
164174
const fileUrl = new URL(`/file?size=${5 * 1024}&seed=789&throttle=1000`, url).toString();
165-
await crawler.run([fileUrl]);
175+
const stats = await crawler.run([fileUrl]);
176+
177+
expect(stats.requestsFailed).toBe(0);
166178

167179
// Wait for the stream to finish (pipeline is async)
168180
await new Promise<void>((resolve) => {

0 commit comments

Comments
 (0)