From 100abf4b701534f44000cbdf4a28e6e19b404db8 Mon Sep 17 00:00:00 2001 From: MLuc24 Date: Tue, 18 Aug 2026 15:28:06 +0700 Subject: [PATCH] [playwright-browser-tunnel] Fix stopAsync hanging while waiting for a connection In poll-connection mode the init promise only settles once a client connects. stopAsync() cleared the polling interval but still awaited that promise, so stopping the tunnel before any client arrived never completed. The pending wait is now settled as part of the teardown, and a stop during the wait is treated as an ordinary shutdown by the start loop rather than an error. --- .../src/PlaywrightBrowserTunnel.ts | 53 +++++++++++++++++-- ...top-while-waiting_2026-08-18-08-27-59.json | 10 ++++ 2 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 common/changes/@rushstack/playwright-browser-tunnel/fix-playwright-tunnel-stop-while-waiting_2026-08-18-08-27-59.json diff --git a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts index 1fa0627b531..eb7ca91ae73 100644 --- a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts +++ b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts @@ -87,6 +87,15 @@ interface IBrowserServerProxy { * Hosts a Playwright browser server and forwards traffic over a WebSocket tunnel. * @beta */ +/** + * Thrown internally to settle a connection wait that was still pending when the tunnel was stopped. + */ +class TunnelStoppedError extends Error { + public constructor() { + super('The tunnel was stopped while waiting for a connection'); + } +} + export class PlaywrightTunnel { private readonly _terminal: ITerminal; private readonly _onStatusChange: (status: TunnelStatus) => void; @@ -97,6 +106,7 @@ export class PlaywrightTunnel { private readonly _playwrightInstallPath: string; private _status: TunnelStatus = 'stopped'; private _initWsPromise?: Promise; + private _cancelPollConnection?: (error: Error) => void; private _keepRunning: boolean = false; private _ws?: WebSocket; private _mode: TunnelMode; @@ -163,7 +173,14 @@ export class PlaywrightTunnel { } else { terminal.writeLine(`Tunnel is already running with status: ${this.status}`); } - await this.waitForCloseAsync(); + try { + await this.waitForCloseAsync(); + } catch (error) { + // stopAsync() settles a pending connection wait; that is an ordinary shutdown, not a failure + if (this._keepRunning || !(error instanceof TunnelStoppedError)) { + throw error; + } + } } } @@ -173,9 +190,28 @@ export class PlaywrightTunnel { clearInterval(this._pollInterval); this._pollInterval = undefined; } - await this._initWsPromise?.finally(() => { - this._ws?.close(WebSocketCloseCode.NORMAL_CLOSURE, 'Tunnel stopped'); - }); + this._pendingConnectionAttempt = undefined; + + // In poll-connection mode the init promise only settles once a client connects. Clearing the + // interval stops the polling but leaves that promise pending forever, so stopping before any + // client arrived would never complete. Settle it explicitly as part of the teardown. + const cancelPollConnection: ((error: Error) => void) | undefined = this._cancelPollConnection; + this._cancelPollConnection = undefined; + cancelPollConnection?.(new TunnelStoppedError()); + + const initWsPromise: Promise | undefined = this._initWsPromise; + this._initWsPromise = undefined; + try { + await initWsPromise?.finally(() => { + this._ws?.close(WebSocketCloseCode.NORMAL_CLOSURE, 'Tunnel stopped'); + }); + } catch (error) { + if (!(error instanceof TunnelStoppedError)) { + throw error; + } + } + + this.status = 'stopped'; } public async [Symbol.asyncDispose](): Promise { @@ -272,6 +308,14 @@ export class PlaywrightTunnel { private async _pollConnectionAsync(): Promise { this._terminal.writeLine(`Waiting for WebSocket connection`); return await new Promise((resolve, reject) => { + this._cancelPollConnection = (error: Error): void => { + if (this._pollInterval) { + clearInterval(this._pollInterval); + this._pollInterval = undefined; + } + this._pendingConnectionAttempt = undefined; + reject(error); + }; this._pollInterval = setInterval(() => { if (this._pendingConnectionAttempt) { return; // Skip if a connection attempt is already in progress @@ -284,6 +328,7 @@ export class PlaywrightTunnel { this._pollInterval = undefined; ws.removeAllListeners(); this._pendingConnectionAttempt = undefined; + this._cancelPollConnection = undefined; resolve(ws); }) .catch(() => { diff --git a/common/changes/@rushstack/playwright-browser-tunnel/fix-playwright-tunnel-stop-while-waiting_2026-08-18-08-27-59.json b/common/changes/@rushstack/playwright-browser-tunnel/fix-playwright-tunnel-stop-while-waiting_2026-08-18-08-27-59.json new file mode 100644 index 00000000000..64251bd09eb --- /dev/null +++ b/common/changes/@rushstack/playwright-browser-tunnel/fix-playwright-tunnel-stop-while-waiting_2026-08-18-08-27-59.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "comment": "Fix `PlaywrightTunnel.stopAsync()` hanging when the tunnel is stopped in poll-connection mode before any client has connected.", + "type": "patch", + "packageName": "@rushstack/playwright-browser-tunnel" + } + ], + "packageName": "@rushstack/playwright-browser-tunnel" +}