This commit is contained in:
Matt Van Horn 2026-07-16 03:22:56 +00:00 committed by GitHub
commit 7be3368b74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 159 additions and 10 deletions

View File

@ -11,7 +11,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { spawn as nodeSpawn } from 'child_process';
import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from 'child_process';
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
@ -141,10 +141,13 @@ async function killServer(pid: number): Promise<void> {
if (IS_WINDOWS) {
// taskkill /T /F kills the process tree (Node + Chromium)
try {
Bun.spawnSync(
['taskkill', '/PID', String(pid), '/T', '/F'],
{ stdout: 'pipe', stderr: 'pipe', timeout: 5000 }
);
const proc = nodeSpawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], {
stdout: 'pipe',
stderr: 'pipe',
timeout: 5000,
windowsHide: true,
});
if (proc.error) throw proc.error;
} catch (err: any) {
if (err?.code !== 'ENOENT') throw err;
}
@ -285,6 +288,7 @@ function raiseHeadedWindowMacOS(): void {
nodeSpawn('osascript', ['-e', 'tell application "Google Chrome for Testing" to activate'], {
stdio: 'ignore',
detached: true,
windowsHide: true,
}).unref();
} catch {
// osascript missing or app not present — non-fatal
@ -323,9 +327,13 @@ 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'] });
const proc = nodeSpawnSync('node', ['-e', launcherCode], {
stdio: ['ignore', 'ignore', 'ignore'],
windowsHide: true,
});
if (proc.error) throw proc.error;
} 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 +348,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

@ -13,9 +13,12 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync as nodeSpawnSync } from 'child_process';
import { mkdirSecure } from './file-permissions';
import { safeUnlinkQuiet } from './error-handling';
const IS_WINDOWS = process.platform === 'win32';
export interface BrowseConfig {
projectDir: string;
stateDir: string;
@ -31,6 +34,16 @@ export interface BrowseConfig {
*/
export function getGitRoot(): string | null {
try {
if (IS_WINDOWS) {
const proc = nodeSpawnSync('git', ['rev-parse', '--show-toplevel'], {
stdout: 'pipe',
stderr: 'pipe',
timeout: 2_000, // Don't hang if .git is broken
windowsHide: true,
});
if (proc.error || proc.status !== 0) return null;
return proc.stdout.toString().trim() || null;
}
const proc = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], {
stdout: 'pipe',
stderr: 'pipe',
@ -123,6 +136,21 @@ export function ensureStateDir(config: BrowseConfig): void {
*/
export function getRemoteSlug(): string {
try {
if (IS_WINDOWS) {
const proc = nodeSpawnSync('git', ['remote', 'get-url', 'origin'], {
stdout: 'pipe',
stderr: 'pipe',
timeout: 2_000,
windowsHide: true,
});
if (proc.error || proc.status !== 0) throw new Error('no remote');
const url = proc.stdout.toString().trim();
// SSH: git@github.com:owner/repo.git → owner-repo
// HTTPS: https://github.com/owner/repo.git → owner-repo
const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
if (match) return `${match[1]}-${match[2]}`;
throw new Error('unparseable');
}
const proc = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'], {
stdout: 'pipe',
stderr: 'pipe',

View File

@ -71,7 +71,7 @@ export function restrictFilePermissions(filePath: string): void {
execFileSync(
'icacls',
[filePath, '/inheritance:r', '/grant:r', `${user}:(F)`],
{ stdio: 'ignore' },
{ stdio: 'ignore', windowsHide: true },
);
} catch (err) {
warnIcaclsFailure(filePath, err);
@ -101,7 +101,7 @@ export function restrictDirectoryPermissions(dirPath: string): void {
execFileSync(
'icacls',
[dirPath, '/inheritance:r', '/grant:r', `${user}:(OI)(CI)(F)`],
{ stdio: 'ignore' },
{ stdio: 'ignore', windowsHide: true },
);
} catch (err) {
warnIcaclsFailure(dirPath, err);

View File

@ -8,17 +8,31 @@
import { accessSync, constants } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { spawnSync as nodeSpawnSync } from 'child_process';
const IS_WINDOWS = process.platform === 'win32';
// ─── Binary Discovery ───────────────────────────────────────────
function getGitRoot(): string | null {
try {
if (IS_WINDOWS) {
const proc = nodeSpawnSync('git', ['rev-parse', '--show-toplevel'], {
stdout: 'pipe',
stderr: 'pipe',
timeout: 2_000,
windowsHide: true,
});
if (proc.error || proc.status !== 0) return null;
return proc.stdout.toString().trim() || null;
}
const proc = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], {
stdout: 'pipe',
stderr: 'pipe',
timeout: 2_000,
});
if (proc.exitCode !== 0) return null;
return proc.stdout.toString().trim();
return proc.stdout.toString().trim() || null;
} catch {
return null;
}

View File

@ -0,0 +1,98 @@
/**
* Static regression coverage for #1784.
*
* Bun.spawnSync does not honor windowsHide on Windows (Bun 1.3.11), so
* cold-start console-subsystem helpers must route through Node's
* child_process APIs with windowsHide:true. These checks pin the spawn sites
* that otherwise create focus-stealing conhost.exe windows.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..', '..');
const CLI = path.join(ROOT, 'browse', 'src', 'cli.ts');
const CONFIG = path.join(ROOT, 'browse', 'src', 'config.ts');
const FIND_BROWSE = path.join(ROOT, 'browse', 'src', 'find-browse.ts');
const FILE_PERMISSIONS = path.join(ROOT, 'browse', 'src', 'file-permissions.ts');
function read(filePath: string): string {
return fs.readFileSync(filePath, 'utf-8');
}
describe('#1784 Windows console flash suppression', () => {
test('Windows detach launcher uses hidden Node spawnSync', () => {
const body = read(CLI);
expect(body).toMatch(/spawnSync as nodeSpawnSync/);
expect(body).toMatch(
/nodeSpawnSync\(\s*['"]node['"],\s*\[\s*['"]-e['"],\s*launcherCode\s*\][\s\S]{0,250}windowsHide:\s*true/,
);
expect(body).not.toMatch(/Bun\.spawnSync\(\s*\[\s*['"]node['"]/);
});
test('Windows taskkill helper uses hidden Node spawnSync', () => {
const body = read(CLI);
expect(body).toMatch(
/nodeSpawnSync\(\s*['"]taskkill['"][\s\S]{0,250}windowsHide:\s*true/,
);
expect(body).not.toMatch(/Bun\.spawnSync\(\s*\[\s*['"]taskkill['"]/);
});
test('git probes use hidden Node spawnSync on Windows and keep Bun on POSIX', () => {
const body = read(CONFIG);
const hiddenGitSpawns = body.match(
/nodeSpawnSync\(\s*['"]git['"][\s\S]{0,250}windowsHide:\s*true/g,
) || [];
expect(body).toContain("process.platform === 'win32'");
expect(hiddenGitSpawns).toHaveLength(2);
expect(body).toMatch(
/Bun\.spawnSync\(\s*\[\s*['"]git['"],\s*['"]rev-parse['"],\s*['"]--show-toplevel['"]/,
);
expect(body).toMatch(
/Bun\.spawnSync\(\s*\[\s*['"]git['"],\s*['"]remote['"],\s*['"]get-url['"],\s*['"]origin['"]/,
);
});
test('find-browse git probe uses hidden Node spawnSync on Windows and keeps Bun on POSIX', () => {
const body = read(FIND_BROWSE);
const hiddenGitSpawns = body.match(
/nodeSpawnSync\(\s*['"]git['"][\s\S]{0,250}windowsHide:\s*true/g,
) || [];
expect(body).toContain("process.platform === 'win32'");
expect(hiddenGitSpawns).toHaveLength(1);
expect(body).toMatch(
/Bun\.spawnSync\(\s*\[\s*['"]git['"],\s*['"]rev-parse['"],\s*['"]--show-toplevel['"]/,
);
});
test('icacls ACL helpers pass windowsHide to execFileSync', () => {
const body = read(FILE_PERMISSIONS);
const hiddenIcaclsCalls = body.match(
/execFileSync\(\s*['"]icacls['"][\s\S]{0,250}windowsHide:\s*true/g,
) || [];
expect(hiddenIcaclsCalls).toHaveLength(2);
});
});
describe('detached server spawns carry windowsHide (#1863 fold-in)', () => {
test('Windows Node launcher inner spawn carries windowsHide:true', () => {
const body = read(CLI);
expect(body).toMatch(/spawn\(process\.execPath,[\s\S]{0,500}detached:true,windowsHide:true/);
});
test('non-Windows server nodeSpawn carries windowsHide:true', () => {
const body = read(CLI);
expect(body).toMatch(/nodeSpawn\('bun',[\s\S]{0,500}detached:\s*true[\s\S]{0,100}windowsHide:\s*true/);
});
test('every detached spawn site in cli.ts carries windowsHide:true', () => {
const body = read(CLI)
.split('\n')
.filter((line) => !line.trim().startsWith('//') && !line.trim().startsWith('*'))
.join('\n');
const detachedSpawns = body.match(/detached:\s*true/g)?.length ?? 0;
const windowsHideFlags = body.match(/windowsHide:\s*true/g)?.length ?? 0;
expect(windowsHideFlags).toBeGreaterThanOrEqual(detachedSpawns);
});
});