Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions browse/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand Down
28 changes: 28 additions & 0 deletions browse/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions browse/src/file-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
85 changes: 85 additions & 0 deletions browse/test/cli-windows-hide.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* 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 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('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);
});
});
Loading