Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/browserLaunch.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test';
import {
buildXvfbBrowserEnv,
CancellableBrowserLaunch,
createXvfbManager,
getGpuWorkaroundArgs,
isXvfbSupported,
Expand Down Expand Up @@ -118,3 +119,41 @@ describe('isXvfbSupported', () => {
expect(isXvfbSupported('win32')).toBeFalse();
});
});

describe('CancellableBrowserLaunch', () => {
test('closes a browser that finishes launching after close()', async () => {
const events: string[] = [];
let resolveLaunch!: (browser: { close: () => Promise<void> }) => void;
const launchPromise = new Promise<{ close: () => Promise<void> }>(
(resolve) => {
resolveLaunch = resolve;
},
);

const session = new CancellableBrowserLaunch(
() => launchPromise as Promise<never>,
);
const launching = session.launch();
const closing = session.close();
let closed = false;
resolveLaunch({
close: async () => {
if (closed) return;
closed = true;
events.push('close');
},
});

await expect(launching).rejects.toThrow('Download cancelled');
await closing;
expect(events).toEqual(['close']);
});

test('rejects a late launch after close()', async () => {
const session = new CancellableBrowserLaunch(async () => {
throw new Error('should not launch');
});
await session.close();
await expect(session.launch()).rejects.toThrow('Download cancelled');
});
});
59 changes: 59 additions & 0 deletions src/browserLaunch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,62 @@ export async function launchAppBrowser(
throw error;
}
}

/**
* Tracks an in-flight Chromium launch so cancel can close the browser even when
* `close()` races `initialize()` before `this.browser` is assigned.
*/
export class CancellableBrowserLaunch {
private browser: Browser | undefined;
private inflight: Promise<Browser> | null = null;
private closed = false;

constructor(
private readonly launchBrowser: (
options?: AppBrowserLaunchOptions,
) => Promise<Browser> = launchAppBrowser,
) {}

async launch(options: AppBrowserLaunchOptions = {}): Promise<Browser> {
if (this.closed) {
throw new Error('Download cancelled');
}
const inflight = this.launchBrowser(options);
this.inflight = inflight;
try {
const browser = await inflight;
this.browser = browser;
if (this.closed) {
this.browser = undefined;
await browser.close().catch(() => {});
throw new Error('Download cancelled');
}
return browser;
} finally {
if (this.inflight === inflight) this.inflight = null;
}
}

async launchConfigured(config: HypedditConfig): Promise<Browser> {
return this.launch({
...browserModeToLaunchOptions(config.browserMode),
userDataDir: config.userDataDir ?? './browser-data',
});
}

async close(): Promise<void> {
this.closed = true;
const inflight = this.inflight;
if (inflight) {
try {
const browser = await inflight;
await browser.close().catch(() => {});
} catch {
// Launch failed or already closed after cancel.
}
}
const browser = this.browser;
this.browser = undefined;
await browser?.close().catch(() => {});
}
}
7 changes: 4 additions & 3 deletions src/downloadgater.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Presets, SingleBar } from 'cli-progress';
import type { Browser, Page } from 'puppeteer';
import { launchConfiguredBrowser } from './browserLaunch';
import { CancellableBrowserLaunch } from './browserLaunch';
import type { ProgressCallback } from './hypeddit';
import Selectors from './selectors';
import { waitForSoundcloudLogin } from './soundcloudLogin';
Expand All @@ -9,6 +9,7 @@ import { loadCookies, timeout } from './utils';

export class DownloadgaterDownloader {
private browser!: Browser;
private readonly browserLaunch = new CancellableBrowserLaunch();
private downloadFilename: string | null = null;
private config: HypedditConfig;
private progressCallback: ProgressCallback | null = null;
Expand All @@ -32,7 +33,7 @@ export class DownloadgaterDownloader {
}

async initialize() {
this.browser = await launchConfiguredBrowser(this.config);
this.browser = await this.browserLaunch.launchConfigured(this.config);

const browserContext = this.browser.defaultBrowserContext();
const soundCloudCookies = await loadCookies('soundcloud-cookies.json');
Expand Down Expand Up @@ -170,7 +171,7 @@ export class DownloadgaterDownloader {
async close() {
this.cancelPendingDownloadWait?.();
this.cancelPendingDownloadWait = null;
await this.browser?.close();
await this.browserLaunch.close();
}

private async clickFreeDownload(page: Page) {
Expand Down
7 changes: 4 additions & 3 deletions src/droploud.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Presets, SingleBar } from 'cli-progress';
import type { Browser, Page } from 'puppeteer';
import { launchConfiguredBrowser } from './browserLaunch';
import { CancellableBrowserLaunch } from './browserLaunch';
import type { ProgressCallback } from './hypeddit';
import Selectors from './selectors';
import { SoundcloudClient } from './soundcloud';
Expand All @@ -19,6 +19,7 @@ type PaneKind =

export class DroploudDownloader {
private browser!: Browser;
private readonly browserLaunch = new CancellableBrowserLaunch();
private downloadFilename: string | null = null;
private config: HypedditConfig;
private progressCallback: ProgressCallback | null = null;
Expand All @@ -42,7 +43,7 @@ export class DroploudDownloader {
}

async initialize() {
this.browser = await launchConfiguredBrowser(this.config);
this.browser = await this.browserLaunch.launchConfigured(this.config);

const browserContext = this.browser.defaultBrowserContext();
const soundCloudCookies = await loadCookies('soundcloud-cookies.json');
Expand Down Expand Up @@ -179,7 +180,7 @@ export class DroploudDownloader {
async close() {
this.cancelPendingDownloadWait?.();
this.cancelPendingDownloadWait = null;
await this.browser?.close();
await this.browserLaunch.close();
}

private async dismissCookieBanner(page: Page) {
Expand Down
28 changes: 26 additions & 2 deletions src/gateCancellation.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, expect, test } from 'bun:test';
import { DownloadgaterDownloader } from './downloadgater';
import { DroploudDownloader } from './droploud';
import { GaterushDownloader } from './gaterush';
import { HypedditDownloader } from './hypeddit';
import { MypresskitDownloader } from './mypresskit';
import { PumpyoursoundDownloader } from './pumpyoursound';
import { StillhypeDownloader } from './stillhype';
import type { HypedditConfig } from './types';

Expand Down Expand Up @@ -30,7 +32,7 @@ async function expectPendingWaitCancelled(
events.push('cancel');
rejectPending(new Error('Download was canceled'));
},
browser: {
browserLaunch: {
close: async () => {
events.push('close');
},
Expand All @@ -46,6 +48,7 @@ async function expectPendingWaitCancelled(
describe('browser gate cancellation', () => {
for (const [name, create] of [
['Droploud', () => new DroploudDownloader(config)],
['GateRush', () => new GaterushDownloader(config)],
['DownloadGater', () => new DownloadgaterDownloader(config)],
['StillHype', () => new StillhypeDownloader(config)],
['Hypeddit', () => new HypedditDownloader(config)],
Expand All @@ -64,7 +67,7 @@ describe('browser gate cancellation', () => {
});
Object.assign(downloader, {
downloadAbortController,
browser: {
browserLaunch: {
close: async () => {
events.push('close');
},
Expand All @@ -75,4 +78,25 @@ describe('browser gate cancellation', () => {

expect(events).toEqual(['abort', 'close']);
});

test('PumpYourSound closes its direct downloader before the browser', async () => {
const downloader = new PumpyoursoundDownloader(config);
const events: string[] = [];
Object.assign(downloader, {
directDownloader: {
close: async () => {
events.push('direct');
},
},
browserLaunch: {
close: async () => {
events.push('close');
},
},
});

await downloader.close();

expect(events).toEqual(['direct', 'close']);
});
});
7 changes: 4 additions & 3 deletions src/gaterush.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Presets, SingleBar } from 'cli-progress';
import type { Browser, Page } from 'puppeteer';
import { launchConfiguredBrowser } from './browserLaunch';
import { CancellableBrowserLaunch } from './browserLaunch';
import type { ProgressCallback } from './hypeddit';
import Selectors from './selectors';
import {
Expand All @@ -12,6 +12,7 @@ import { loadCookies, timeout } from './utils';

export class GaterushDownloader {
private browser!: Browser;
private readonly browserLaunch = new CancellableBrowserLaunch();
private downloadFilename: string | null = null;
private config: HypedditConfig;
private progressCallback: ProgressCallback | null = null;
Expand All @@ -35,7 +36,7 @@ export class GaterushDownloader {
}

async initialize() {
this.browser = await launchConfiguredBrowser(this.config);
this.browser = await this.browserLaunch.launchConfigured(this.config);

const browserContext = this.browser.defaultBrowserContext();
const soundCloudCookies = await loadCookies('soundcloud-cookies.json');
Expand Down Expand Up @@ -135,7 +136,7 @@ export class GaterushDownloader {
async close() {
this.cancelPendingDownloadWait?.();
this.cancelPendingDownloadWait = null;
await this.browser?.close();
await this.browserLaunch.close();
}

private async dismissCookieBanner(page: Page) {
Expand Down
10 changes: 7 additions & 3 deletions src/hypeddit.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Presets, SingleBar } from 'cli-progress';
import type { Browser, Page } from 'puppeteer';
import { browserModeToLaunchOptions, launchAppBrowser } from './browserLaunch';
import {
browserModeToLaunchOptions,
CancellableBrowserLaunch,
} from './browserLaunch';
import Selectors from './selectors';
import { waitForSoundcloudLogin } from './soundcloudLogin';
import type { HypedditConfig, JobProgress, JobStage } from './types';
Expand All @@ -22,6 +25,7 @@ interface GateDefinition {

export class HypedditDownloader {
private browser!: Browser; // null-asserted because it is initialized async and every call to it comes logically after the init
private readonly browserLaunch = new CancellableBrowserLaunch();
private downloadFilename: string | null = null;
private config: HypedditConfig;
private spotifyCookiesExists = false;
Expand Down Expand Up @@ -102,7 +106,7 @@ export class HypedditDownloader {
}

async initialize() {
this.browser = await launchAppBrowser({
this.browser = await this.browserLaunch.launch({
...browserModeToLaunchOptions(this.config.browserMode),
userDataDir: this.config.userDataDir ?? './browser-data',
});
Expand Down Expand Up @@ -354,7 +358,7 @@ export class HypedditDownloader {
async close() {
this.cancelPendingDownloadWait?.();
this.cancelPendingDownloadWait = null;
await this.browser?.close();
await this.browserLaunch.close();
}

private async handleEmailSlide(page: Page) {
Expand Down
10 changes: 7 additions & 3 deletions src/mypresskit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { mkdir, rm } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { Presets, SingleBar } from 'cli-progress';
import type { Browser, Page } from 'puppeteer';
import { browserModeToLaunchOptions, launchAppBrowser } from './browserLaunch';
import {
browserModeToLaunchOptions,
CancellableBrowserLaunch,
} from './browserLaunch';
import type { ProgressCallback } from './hypeddit';
import { safeFetch } from './safeOutboundUrl';
import Selectors from './selectors';
Expand Down Expand Up @@ -63,6 +66,7 @@ function safePageUrl(page: Page): string {

export class MypresskitDownloader {
private browser!: Browser;
private readonly browserLaunch = new CancellableBrowserLaunch();
private config: HypedditConfig;
private progressCallback: ProgressCallback | null = null;
private readonly downloadAbortController = new AbortController();
Expand All @@ -85,7 +89,7 @@ export class MypresskitDownloader {
}

async initialize() {
this.browser = await launchAppBrowser({
this.browser = await this.browserLaunch.launch({
...browserModeToLaunchOptions(this.config.browserMode),
userDataDir: this.config.userDataDir ?? './browser-data',
// SC OAuth authorize popup shares session cookies more reliably with this.
Expand Down Expand Up @@ -225,7 +229,7 @@ export class MypresskitDownloader {

async close() {
this.downloadAbortController.abort();
await this.browser?.close();
await this.browserLaunch.close();
}

private async dismissCookies(page: Page) {
Expand Down
10 changes: 7 additions & 3 deletions src/pumpyoursound.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { Browser, Page } from 'puppeteer';
import { browserModeToLaunchOptions, launchAppBrowser } from './browserLaunch';
import {
browserModeToLaunchOptions,
CancellableBrowserLaunch,
} from './browserLaunch';
import { DirectDownloader } from './directDownload';
import type { ProgressCallback } from './hypeddit';
import Selectors from './selectors';
Expand All @@ -22,6 +25,7 @@ const SC_AUTHORIZE_RE = /secure\.soundcloud\.com\/authorize/i;
*/
export class PumpyoursoundDownloader {
private browser!: Browser;
private readonly browserLaunch = new CancellableBrowserLaunch();
private directDownloader: DirectDownloader | null = null;
private downloadFilename: string | null = null;
private config: HypedditConfig;
Expand All @@ -45,7 +49,7 @@ export class PumpyoursoundDownloader {
}

async initialize() {
this.browser = await launchAppBrowser({
this.browser = await this.browserLaunch.launch({
...browserModeToLaunchOptions(this.config.browserMode),
userDataDir: this.config.userDataDir ?? './browser-data',
// SC.connect authorize popup shares session cookies more reliably with this.
Expand Down Expand Up @@ -142,7 +146,7 @@ export class PumpyoursoundDownloader {

async close() {
await this.directDownloader?.close();
await this.browser?.close();
await this.browserLaunch.close();
}

private async dismissOverlays(page: Page) {
Expand Down
Loading