fix(windows): patch playwright-core processLauncher to pass windowsHide

chrome-headless-shell is a console-subsystem exe; launched by
playwright-core from the console-less browse daemon on Windows it
allocates a visible console window per browser launch, and the
launcher force-kill path shells out to taskkill (a cmd.exe window per
kill — the node_modules twin of the vendored-copy gap in #1989).
Upstream playwright sets no windowsHide and offers no knob, so this
cannot be fixed from gstack call sites the way #2153 fixed the
polyfill spawns.

Carry a bun patchedDependencies patch adding windowsHide: true to the
launcher spawnOptions and the taskkill spawnSync options. Applies
automatically on every bun install. Tripwire test reads the INSTALLED
node_modules file so a playwright version bump that orphans the
exact-version patch key fails CI instead of silently re-flashing
consoles.

Fixes #2160.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Southgate 2026-07-03 11:25:34 +01:00
parent 11de390be1
commit eea2192758
4 changed files with 82 additions and 0 deletions

View File

@ -0,0 +1,52 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
// Windows console-flash guard for the Playwright browser launcher (#2151
// follow-up; siblings: #1835, #1989).
//
// On Windows the browse server runs console-less (node server-node.mjs,
// detached from the CLI). chrome-headless-shell is a console-subsystem
// exe, so playwright-core's processLauncher spawning it without
// windowsHide allocates a visible console window per launch, and its
// force-kill path shells out to `taskkill` (another window). Upstream
// playwright doesn't set windowsHide, so we carry a bun
// `patchedDependencies` patch. These tripwires pin:
// 1. package.json declares the patch for the installed version — if a
// playwright bump orphans the patch key, test 3 catches the silent
// regression (bun applies patches per exact version).
// 2. the patch file itself still contains both windowsHide sites.
// 3. the INSTALLED node_modules file actually got patched.
const ROOT = path.resolve(import.meta.dir, '..', '..');
const INSTALLED_LAUNCHER = path.join(
ROOT, 'node_modules', 'playwright-core', 'lib', 'server', 'utils', 'processLauncher.js',
);
describe('playwright-core processLauncher windowsHide patch', () => {
test('1. package.json declares a patchedDependencies entry matching the installed playwright-core version', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
const installed = JSON.parse(fs.readFileSync(
path.join(ROOT, 'node_modules', 'playwright-core', 'package.json'), 'utf-8',
));
const key = `playwright-core@${installed.version}`;
expect(pkg.patchedDependencies?.[key]).toBeDefined();
expect(fs.existsSync(path.join(ROOT, pkg.patchedDependencies[key]))).toBe(true);
});
test('2. the patch covers both spawn sites (browser launch + taskkill)', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
const patchRel = Object.entries(pkg.patchedDependencies ?? {})
.find(([k]) => k.startsWith('playwright-core@'))?.[1] as string;
const patch = fs.readFileSync(path.join(ROOT, patchRel), 'utf-8');
expect(patch).toContain('+ windowsHide: true,');
expect(patch).toContain('{ shell: true, windowsHide: true }');
});
test('3. the installed processLauncher.js is actually patched', () => {
const src = fs.readFileSync(INSTALLED_LAUNCHER, 'utf-8');
const launchBlock = src.slice(src.indexOf('async function launchProcess'), src.indexOf('const spawnedProcess'));
expect(launchBlock).toContain('windowsHide: true');
expect(src).toMatch(/taskkill[^\n]*\{ shell: true, windowsHide: true \}/);
});
});

View File

@ -22,6 +22,9 @@
},
},
},
"patchedDependencies": {
"playwright-core@1.58.2": "patches/playwright-core@1.58.2.patch",
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.117", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.117", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.117", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.117", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.117" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-pVBss1Vu0w87nKCBhWtjMggSgCh6GVUtdRmuE58ZvXv0E2q0JcnUCQHehmn92BAW0+VCwPY8q/k7uKWkgwz/gA=="],

View File

@ -76,5 +76,8 @@
"@anthropic-ai/sdk": "^0.78.0",
"xterm": "5",
"xterm-addon-fit": "^0.8.0"
},
"patchedDependencies": {
"playwright-core@1.58.2": "patches/playwright-core@1.58.2.patch"
}
}

View File

@ -0,0 +1,24 @@
diff --git a/lib/server/utils/processLauncher.js b/lib/server/utils/processLauncher.js
index 954d5e6fbc527e640ccec5709263374101c6340b..6913f7e9cafe6bcc57ce5a501ce05edc01d36e0c 100644
--- a/lib/server/utils/processLauncher.js
+++ b/lib/server/utils/processLauncher.js
@@ -112,6 +112,10 @@ async function launchProcess(options) {
env: options.env,
cwd: options.cwd,
shell: options.shell,
+ // gstack patch (#2151 follow-up): chrome-headless-shell is a console-
+ // subsystem exe; launched from the console-less browse daemon on
+ // Windows it allocates a visible console window without CREATE_NO_WINDOW.
+ windowsHide: true,
stdio
};
const spawnedProcess = childProcess.spawn(options.command, options.args || [], spawnOptions);
@@ -191,7 +195,7 @@ async function launchProcess(options) {
options.log(`[pid=${spawnedProcess.pid}] <will force kill>`);
try {
if (process.platform === "win32") {
- const taskkillProcess = childProcess.spawnSync(`taskkill /pid ${spawnedProcess.pid} /T /F`, { shell: true });
+ const taskkillProcess = childProcess.spawnSync(`taskkill /pid ${spawnedProcess.pid} /T /F`, { shell: true, windowsHide: true });
const [stdout2, stderr2] = [taskkillProcess.stdout.toString(), taskkillProcess.stderr.toString()];
if (stdout2)
options.log(`[pid=${spawnedProcess.pid}] taskkill stdout: ${stdout2}`);