fix(browse): windowsHide on every Windows-reachable spawn (#1835)

Console windows flashed (and stole focus) on every daemon relaunch,
taskkill, tasklist poll, and powershell DPAPI call — node-level spawns
default windowsHide to false. Covered: the node -e launcher (outer spawnSync
AND the inner detached daemon spawn inside the launcher string), the
dev-mode bun fallback, killServer's taskkill, isProcessAlive's tasklist,
and cookie-import's powershell + tasklist. The Bun-polyfill shims were
covered by absorbed PRs #2523 + #2539 (thanks @jwilk-hrep,
@jerrynicholsai); this closes the sites those PRs didn't reach. The icacls
sites land with the #1605 DACL commit alongside the static tripwire that
pins all of them. R8's planned spawnHidden() helper is deliberately NOT
built: the polyfill default plus the tripwire achieve the no-drift goal
without indirection over seven heterogeneous call shapes. The polyfill +
spawn-hide tests join the Windows CI shard.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-08-14 12:53:33 -07:00
parent 427a55d570
commit 270f1a038a
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
4 changed files with 9 additions and 5 deletions

View File

@ -111,6 +111,8 @@ jobs:
browse/test/claude-bin.test.ts \
test/test-free-shards.test.ts \
browse/test/file-permissions.test.ts \
browse/test/bun-polyfill.test.ts \
browse/test/windows-spawn-hide.test.ts \
browse/test/security.test.ts \
browse/test/server-sanitize-surrogates.test.ts \
test/setup-windows-fallback.test.ts \

View File

@ -143,7 +143,7 @@ async function killServer(pid: number): Promise<void> {
try {
Bun.spawnSync(
['taskkill', '/PID', String(pid), '/T', '/F'],
{ stdout: 'pipe', stderr: 'pipe', timeout: 5000 }
{ stdout: 'pipe', stderr: 'pipe', timeout: 5000, windowsHide: true }
);
} catch (err: any) {
if (err?.code !== 'ENOENT') throw err;
@ -323,9 +323,9 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
const launcherCode =
`const{spawn}=require('child_process');` +
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
`{detached:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
`{detached:true,windowsHide:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
`${extraEnvStr})}).unref()`;
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true });
} else {
// macOS/Linux: Bun.spawn().unref() only removes the child from Bun's event
// loop — it does NOT call setsid(), so the spawned server stays in the
@ -340,6 +340,7 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
// the Windows path's rationale — same root cause, different OS API.
nodeSpawn('bun', ['run', SERVER_SCRIPT], {
detached: true,
windowsHide: true,
stdio: ['ignore', 'ignore', 'ignore'],
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...extraEnv },
}).unref();

View File

@ -526,6 +526,7 @@ async function dpapiDecrypt(encryptedBytes: Buffer): Promise<Buffer> {
].join('; ');
const proc = Bun.spawn(['powershell', '-NoProfile', '-Command', script], {
windowsHide: true,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
@ -778,7 +779,7 @@ function isBrowserRunning(browserName: string): Promise<boolean> {
const exe = browserName.toLowerCase().includes('edge') ? 'msedge.exe' : 'chrome.exe';
return new Promise((resolve) => {
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
stdout: 'pipe', stderr: 'pipe',
stdout: 'pipe', stderr: 'pipe', windowsHide: true,
});
proc.exited.then(async () => {
const out = await new Response(proc.stdout).text();

View File

@ -42,7 +42,7 @@ export function isProcessAlive(pid: number): boolean {
try {
const result = Bun.spawnSync(
['tasklist', '/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'],
{ stdout: 'pipe', stderr: 'pipe', timeout: 3000 }
{ stdout: 'pipe', stderr: 'pipe', timeout: 3000, windowsHide: true }
);
return result.stdout.toString().includes(`"${pid}"`);
} catch {