Skip to content

Commit b684d04

Browse files
committed
Fix Playwright tunnel restart race
1 parent bf1381b commit b684d04

6 files changed

Lines changed: 153 additions & 14 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "playwright-local-browser-server",
5+
"comment": "Fix a race where restarting the Playwright tunnel could create a replacement before the previous tunnel finished stopping.",
6+
"type": "patch"
7+
}
8+
],
9+
"packageName": "playwright-local-browser-server",
10+
"email": "9123665+justinTM@users.noreply.github.com"
11+
}

common/config/subspaces/default/pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vscode-extensions/playwright-local-browser-server-vscode-extension/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"build": "heft build --clean",
3535
"build:watch": "heft build-watch",
3636
"start": "heft start",
37+
"test:vscode": "node ./lib/vscodeTest/runTest.js",
3738
"_phase:build": "heft run --only build -- --clean",
3839
"_phase:test": ""
3940
},
@@ -108,7 +109,8 @@
108109
"@rushstack/heft": "workspace:*",
109110
"@types/node": "20.17.19",
110111
"@types/vscode": "1.103.0",
111-
"@types/webpack-env": "1.18.8"
112+
"@types/webpack-env": "1.18.8",
113+
"@vscode/test-electron": "^1.6.2"
112114
},
113115
"sideEffects": false
114116
}

vscode-extensions/playwright-local-browser-server-vscode-extension/src/extension.ts

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
157157

158158
// Tunnel instance
159159
let tunnel: PlaywrightTunnel | undefined;
160+
let stopTunnelPromise: Promise<void> | undefined;
160161

161162
function getTmpPath(): string {
162163
return path.join(os.tmpdir(), 'playwright-browser-tunnel');
@@ -290,6 +291,12 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
290291
}
291292

292293
async function handleStartTunnelAsync(isAutoStart: boolean = false): Promise<void> {
294+
const pendingStopPromise: Promise<void> | undefined = stopTunnelPromise;
295+
if (pendingStopPromise) {
296+
outputChannel.appendLine('Waiting for Playwright tunnel to stop before starting again...');
297+
await pendingStopPromise;
298+
}
299+
293300
// Store current tunnel reference to avoid race conditions
294301
const existingTunnel: PlaywrightTunnel | undefined = tunnel;
295302
if (existingTunnel && currentStatus !== 'stopped' && currentStatus !== 'error') {
@@ -439,27 +446,39 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
439446
}
440447

441448
async function handleStopTunnelAsync(): Promise<void> {
449+
const pendingStopPromise: Promise<void> | undefined = stopTunnelPromise;
450+
if (pendingStopPromise) {
451+
outputChannel.appendLine('Tunnel stop already in progress.');
452+
await pendingStopPromise;
453+
return;
454+
}
455+
442456
const currentTunnel: PlaywrightTunnel | undefined = tunnel;
443457
if (!currentTunnel) {
444458
outputChannel.appendLine('No tunnel instance to stop.');
445459
void vscode.window.showInformationMessage('Playwright tunnel is not running.');
446460
return;
447461
}
448462

449-
// Clear the reference before awaiting to avoid race condition
450-
tunnel = undefined;
451-
452-
try {
463+
const currentStopPromise: Promise<void> = (async () => {
453464
outputChannel.appendLine('Stopping Playwright tunnel...');
454-
await currentTunnel.stopAsync();
455-
updateStatusBar('stopped');
456-
outputChannel.appendLine('Tunnel stopped.');
457-
void vscode.window.showInformationMessage('Playwright tunnel stopped.');
458-
} catch (error) {
459-
const errorMessage: string = getNormalizedErrorString(error);
460-
outputChannel.appendLine(`Failed to stop tunnel: ${errorMessage}`);
461-
void vscode.window.showErrorMessage(`Failed to stop Playwright tunnel: ${errorMessage}`);
462-
}
465+
try {
466+
await currentTunnel.stopAsync();
467+
tunnel = undefined;
468+
updateStatusBar('stopped');
469+
outputChannel.appendLine('Tunnel stopped.');
470+
void vscode.window.showInformationMessage('Playwright tunnel stopped.');
471+
} catch (error) {
472+
const errorMessage: string = getNormalizedErrorString(error);
473+
outputChannel.appendLine(`Failed to stop tunnel: ${errorMessage}`);
474+
void vscode.window.showErrorMessage(`Failed to stop Playwright tunnel: ${errorMessage}`);
475+
} finally {
476+
stopTunnelPromise = undefined;
477+
}
478+
})();
479+
480+
stopTunnelPromise = currentStopPromise;
481+
await currentStopPromise;
463482
}
464483

465484
async function handleShowMenu(): Promise<void> {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import * as os from 'node:os';
5+
import * as path from 'node:path';
6+
7+
import { runTests } from '@vscode/test-electron';
8+
9+
async function main(): Promise<void> {
10+
try {
11+
const extensionDevelopmentPath: string = path.resolve(__dirname, '../../dist/vsix/unpacked');
12+
const extensionTestsPath: string = path.resolve(__dirname, './suite/index');
13+
const testDataPath: string = path.join(os.tmpdir(), 'playwright-local-browser-server-vscode-test');
14+
15+
await runTests({
16+
vscodeExecutablePath: process.env.VSCODE_EXECUTABLE_PATH,
17+
extensionDevelopmentPath,
18+
extensionTestsPath,
19+
launchArgs: [
20+
path.join(testDataPath, 'workspace'),
21+
'--user-data-dir',
22+
path.join(testDataPath, 'user-data'),
23+
'--extensions-dir',
24+
path.join(testDataPath, 'extensions'),
25+
'--disable-workspace-trust',
26+
'--skip-welcome',
27+
'--skip-release-notes'
28+
]
29+
});
30+
} catch (error) {
31+
// eslint-disable-next-line no-console
32+
console.error('Failed to run VS Code extension tests', error);
33+
process.exit(1);
34+
}
35+
}
36+
37+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
38+
main();
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import * as assert from 'node:assert';
5+
6+
import * as vscode from 'vscode';
7+
8+
const EXTENSION_ID: string = 'ms-RushStack.playwright-local-browser-server';
9+
const COMMAND_START_TUNNEL: string = 'playwright-local-browser-server.start';
10+
const COMMAND_STOP_TUNNEL: string = 'playwright-local-browser-server.stop';
11+
12+
function sleepAsync(milliseconds: number): Promise<void> {
13+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
14+
}
15+
16+
async function promiseSettledWithinAsync(promise: Thenable<unknown>, milliseconds: number): Promise<boolean> {
17+
let settled: boolean = false;
18+
promise.then(
19+
() => {
20+
settled = true;
21+
},
22+
() => {
23+
settled = true;
24+
}
25+
);
26+
await sleepAsync(milliseconds);
27+
return settled;
28+
}
29+
30+
export async function run(): Promise<void> {
31+
const extension: vscode.Extension<unknown> | undefined = vscode.extensions.getExtension(EXTENSION_ID);
32+
assert.ok(extension, `Expected ${EXTENSION_ID} to be installed in the VS Code test host.`);
33+
34+
await extension.activate();
35+
36+
const commands: string[] = await vscode.commands.getCommands(true);
37+
assert.ok(commands.includes(COMMAND_START_TUNNEL), `Missing command: ${COMMAND_START_TUNNEL}`);
38+
assert.ok(commands.includes(COMMAND_STOP_TUNNEL), `Missing command: ${COMMAND_STOP_TUNNEL}`);
39+
40+
await vscode.workspace
41+
.getConfiguration('playwright-local-browser-server')
42+
.update('autoStart', true, vscode.ConfigurationTarget.Global);
43+
44+
await vscode.commands.executeCommand(COMMAND_START_TUNNEL, true);
45+
await sleepAsync(250);
46+
47+
const stopPromise: Thenable<unknown> = vscode.commands.executeCommand(COMMAND_STOP_TUNNEL);
48+
await sleepAsync(250);
49+
50+
const restartPromise: Thenable<unknown> = vscode.commands.executeCommand(COMMAND_START_TUNNEL, true);
51+
52+
assert.strictEqual(
53+
await promiseSettledWithinAsync(restartPromise, 1000),
54+
false,
55+
'Start resolved while the previous Stop command was still pending.'
56+
);
57+
58+
assert.strictEqual(
59+
await promiseSettledWithinAsync(stopPromise, 1),
60+
false,
61+
'Stop unexpectedly settled before the restart guard could be observed.'
62+
);
63+
64+
// eslint-disable-next-line no-console
65+
console.log('Playwright tunnel restart remained pending behind the in-progress stop.');
66+
}

0 commit comments

Comments
 (0)