Skip to content

Commit 1f3449c

Browse files
authored
fix(download): do not stall BrowserContext.close waiting for downloads (#5424)
We might not ever get the "download finished" event when closing the context: - in Chromium, for any ongoing download; - in all browsers, for failed downloads. This should not prevent closing the context. Instead of waiting for the download and then deleting it, we force delete it immediately and reject any promises waiting for the download completion.
1 parent 8b9a2af commit 1f3449c

File tree

4 files changed

+77
-12
lines changed

4 files changed

+77
-12
lines changed

src/dispatchers/downloadDispatcher.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export class DownloadDispatcher extends Dispatcher<Download, channels.DownloadIn
3939
return await new Promise((resolve, reject) => {
4040
this._object.saveAs(async (localPath, error) => {
4141
if (error !== undefined) {
42-
reject(error);
42+
reject(new Error(error));
4343
return;
4444
}
4545

@@ -58,7 +58,7 @@ export class DownloadDispatcher extends Dispatcher<Download, channels.DownloadIn
5858
return await new Promise((resolve, reject) => {
5959
this._object.saveAs(async (localPath, error) => {
6060
if (error !== undefined) {
61-
reject(error);
61+
reject(new Error(error));
6262
return;
6363
}
6464

src/server/browserContext.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -252,15 +252,6 @@ export abstract class BrowserContext extends SdkObject {
252252

253253
await this.instrumentation.onContextWillDestroy(this);
254254

255-
// Collect videos/downloads that we will await.
256-
const promises: Promise<any>[] = [];
257-
for (const download of this._downloads)
258-
promises.push(download.delete());
259-
for (const video of this._browser._idToVideo.values()) {
260-
if (video._context === this)
261-
promises.push(video._finishedPromise);
262-
}
263-
264255
if (this._isPersistentContext) {
265256
// Close all the pages instead of the context,
266257
// because we cannot close the default context.
@@ -270,7 +261,18 @@ export abstract class BrowserContext extends SdkObject {
270261
await this._doClose();
271262
}
272263

273-
// Wait for the videos/downloads to finish.
264+
// Cleanup.
265+
const promises: Promise<void>[] = [];
266+
for (const video of this._browser._idToVideo.values()) {
267+
// Wait for the videos to finish.
268+
if (video._context === this)
269+
promises.push(video._finishedPromise);
270+
}
271+
for (const download of this._downloads) {
272+
// We delete downloads after context closure
273+
// so that browser does not write to the download file anymore.
274+
promises.push(download.deleteOnContextClose());
275+
}
274276
await Promise.all(promises);
275277

276278
// Persistent context should also close the browser.

src/server/download.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,22 @@ export class Download {
107107
await util.promisify(fs.unlink)(fileName).catch(e => {});
108108
}
109109

110+
async deleteOnContextClose(): Promise<void> {
111+
// Compared to "delete", this method does not wait for the download to finish.
112+
// We use it when closing the context to avoid stalling.
113+
if (this._deleted)
114+
return;
115+
this._deleted = true;
116+
if (this._acceptDownloads) {
117+
const fileName = path.join(this._downloadsPath, this._uuid);
118+
await util.promisify(fs.unlink)(fileName).catch(e => {});
119+
}
120+
this._reportFinished('Download deleted upon browser context closure.');
121+
}
122+
110123
async _reportFinished(error?: string) {
124+
if (this._finished)
125+
return;
111126
this._finished = true;
112127
this._failure = error || null;
113128

test/download.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,4 +365,52 @@ describe('download event', () => {
365365
expect(fs.existsSync(path2)).toBeFalsy();
366366
expect(fs.existsSync(path.join(path1, '..'))).toBeFalsy();
367367
});
368+
369+
it('should close the context without awaiting the failed download', (test, { browserName }) => {
370+
test.skip(browserName !== 'chromium', 'Only Chromium downloads on alt-click');
371+
}, async ({browser, server, httpsServer, testInfo}) => {
372+
const page = await browser.newPage({ acceptDownloads: true });
373+
await page.goto(server.EMPTY_PAGE);
374+
await page.setContent(`<a href="${httpsServer.PREFIX}/downloadWithFilename" download="file.txt">click me</a>`);
375+
const [download] = await Promise.all([
376+
page.waitForEvent('download'),
377+
// Use alt-click to force the download. Otherwise browsers might try to navigate first,
378+
// probably because of http -> https link.
379+
page.click('a', { modifiers: ['Alt']})
380+
]);
381+
const [downloadPath, saveError] = await Promise.all([
382+
download.path(),
383+
download.saveAs(testInfo.outputPath('download.txt')).catch(e => e),
384+
page.context().close(),
385+
]);
386+
expect(downloadPath).toBe(null);
387+
expect(saveError.message).toContain('Download deleted upon browser context closure.');
388+
});
389+
390+
it('should close the context without awaiting the download', (test, { browserName, platform }) => {
391+
test.skip(browserName === 'webkit' && platform === 'linux', 'WebKit on linux does not convert to the download immediately upon receiving headers');
392+
}, async ({browser, server, testInfo}) => {
393+
server.setRoute('/downloadStall', (req, res) => {
394+
res.setHeader('Content-Type', 'application/octet-stream');
395+
res.setHeader('Content-Disposition', 'attachment; filename=file.txt');
396+
res.writeHead(200);
397+
res.flushHeaders();
398+
res.write(`Hello world`);
399+
});
400+
401+
const page = await browser.newPage({ acceptDownloads: true });
402+
await page.goto(server.EMPTY_PAGE);
403+
await page.setContent(`<a href="${server.PREFIX}/downloadStall" download="file.txt">click me</a>`);
404+
const [download] = await Promise.all([
405+
page.waitForEvent('download'),
406+
page.click('a')
407+
]);
408+
const [downloadPath, saveError] = await Promise.all([
409+
download.path(),
410+
download.saveAs(testInfo.outputPath('download.txt')).catch(e => e),
411+
page.context().close(),
412+
]);
413+
expect(downloadPath).toBe(null);
414+
expect(saveError.message).toContain('Download deleted upon browser context closure.');
415+
});
368416
});

0 commit comments

Comments
 (0)