From 100abf4b701534f44000cbdf4a28e6e19b404db8 Mon Sep 17 00:00:00 2001 From: MLuc24 Date: Tue, 18 Aug 2026 15:28:06 +0700 Subject: [PATCH 1/3] [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" +} From 086c75e2adb98da2f3fdf43dced29714e483ab0d Mon Sep 17 00:00:00 2001 From: MLuc24 Date: Fri, 21 Aug 2026 14:19:19 +0700 Subject: [PATCH 2/3] fix: keep the PlaywrightTunnel release tag attached to its class The new error type was declared between PlaywrightTunnel's doc comment and the class, so the @beta tag bound to the error instead and API Extractor saw PlaywrightTunnel as an undocumented public export. --- .../src/PlaywrightBrowserTunnel.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts index eb7ca91ae73..c2a26233822 100644 --- a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts +++ b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts @@ -83,10 +83,6 @@ interface IBrowserServerProxy { client: WebSocket; } -/** - * 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. */ @@ -96,6 +92,10 @@ class TunnelStoppedError extends Error { } } +/** + * Hosts a Playwright browser server and forwards traffic over a WebSocket tunnel. + * @beta + */ export class PlaywrightTunnel { private readonly _terminal: ITerminal; private readonly _onStatusChange: (status: TunnelStatus) => void; From 9650a54b4166f75af728c0ceb387c341299de61f Mon Sep 17 00:00:00 2001 From: MLuc24 <165113247+MLuc24@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:43:52 +0700 Subject: [PATCH 3/3] fix: retire the poll generation so a late connection cannot disturb the next one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancelling only rejected the outer promise. A _tryConnectAsync() already in flight kept running, and its continuation cleared the interval, the pending attempt and the canceller — state that by then could belong to a poll started after the stop, which would leave the next stop hanging again. It also left the socket it had just opened with no owner. Each poll now takes a generation. A continuation that no longer owns it closes its socket and touches nothing else. --- .../src/PlaywrightBrowserTunnel.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts index c2a26233822..b7085273a1f 100644 --- a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts +++ b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts @@ -107,6 +107,9 @@ export class PlaywrightTunnel { private _status: TunnelStatus = 'stopped'; private _initWsPromise?: Promise; private _cancelPollConnection?: (error: Error) => void; + /// Bumped whenever polling starts or is cancelled, so an attempt that resolves + /// after a stop or restart can recognise that it no longer owns the shared state. + private _pollGeneration: number = 0; private _keepRunning: boolean = false; private _ws?: WebSocket; private _mode: TunnelMode; @@ -307,8 +310,13 @@ export class PlaywrightTunnel { // Need to support multiple simultaneous connections for parallel tests. private async _pollConnectionAsync(): Promise { this._terminal.writeLine(`Waiting for WebSocket connection`); + const generation: number = ++this._pollGeneration; + const ownsPollState = (): boolean => this._pollGeneration === generation; return await new Promise((resolve, reject) => { this._cancelPollConnection = (error: Error): void => { + // Retire this generation so an attempt still in flight cannot clear the + // interval or the canceller belonging to whatever starts next. + this._pollGeneration += 1; if (this._pollInterval) { clearInterval(this._pollInterval); this._pollInterval = undefined; @@ -324,6 +332,13 @@ export class PlaywrightTunnel { this._pendingConnectionAttempt = connectionPromise; connectionPromise .then((ws: WebSocket) => { + if (!ownsPollState()) { + // Stopped or restarted while this attempt was in flight: the socket + // belongs to nobody now, so close it rather than leave it open. + ws.removeAllListeners(); + ws.close(WebSocketCloseCode.NORMAL_CLOSURE, 'Tunnel stopped'); + return; + } clearInterval(this._pollInterval); this._pollInterval = undefined; ws.removeAllListeners(); @@ -333,7 +348,9 @@ export class PlaywrightTunnel { }) .catch(() => { // no-op - will retry on next interval - this._pendingConnectionAttempt = undefined; + if (ownsPollState()) { + this._pendingConnectionAttempt = undefined; + } }); }, 500); });