fix: add windowsHide to all web-mode subprocess spawns (#2628) (#3046)

On Windows, child_process.spawn() and execFile() open a visible console
window by default. The web server spawn, RPC bridge, browser opener, and
all 15 web service subprocess calls were missing windowsHide: true,
causing constant console window flashing when running gsd --web.

Closes #2628

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 16:50:13 -04:00 committed by GitHub
parent 466c7dea18
commit de9ba8aeb7
18 changed files with 143 additions and 1 deletions

View file

@ -164,6 +164,7 @@ test('launchWebMode prefers the packaged standalone host and opens the resolved
cwd: standaloneRoot,
detached: true,
stdio: 'ignore',
windowsHide: true,
env: {
TEST_ENV: '1',
HOSTNAME: '127.0.0.1',

View file

@ -0,0 +1,120 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const webMode = await import("../../web-mode.ts");
// ---------------------------------------------------------------------------
// #2628 — On Windows, child processes spawned by web-mode must set
// `windowsHide: true` to prevent console windows from flashing on screen.
// ---------------------------------------------------------------------------
test("launchWebMode passes windowsHide: true in spawn options", async (t) => {
const tmp = mkdtempSync(join(tmpdir(), "gsd-web-winhide-"));
const standaloneRoot = join(tmp, "dist", "web", "standalone");
const serverPath = join(standaloneRoot, "server.js");
mkdirSync(standaloneRoot, { recursive: true });
writeFileSync(serverPath, 'console.log("stub")\n');
const pidFilePath = join(tmp, "web-server.pid");
const registryPath = join(tmp, "web-instances.json");
let capturedOptions: Record<string, unknown> | undefined;
t.after(() => {
rmSync(tmp, { recursive: true, force: true });
});
const status = await webMode.launchWebMode(
{
cwd: "/tmp/winhide-project",
projectSessionsDir: "/tmp/.gsd/sessions/winhide",
agentDir: "/tmp/.gsd/agent",
packageRoot: tmp,
},
{
initResources: () => {},
resolvePort: async () => 46000,
execPath: "/custom/node",
env: { TEST_ENV: "1" },
spawn: (_command, _args, options) => {
capturedOptions = options as Record<string, unknown>;
return {
pid: 70001,
once: () => undefined,
unref: () => {},
} as any;
},
waitForBootReady: async () => undefined,
openBrowser: () => {},
pidFilePath,
writePidFile: webMode.writePidFile,
registryPath,
stderr: { write: () => true },
},
);
assert.equal(status.ok, true, "launch should succeed");
assert.ok(capturedOptions, "spawn must have been called");
assert.equal(
capturedOptions!.windowsHide,
true,
"spawn options must include windowsHide: true to prevent console window flashing on Windows (#2628)",
);
});
test("launchWebMode source-dev host also passes windowsHide: true", async (t) => {
const tmp = mkdtempSync(join(tmpdir(), "gsd-web-winhide-src-"));
const webRoot = join(tmp, "web");
mkdirSync(webRoot, { recursive: true });
writeFileSync(join(webRoot, "package.json"), '{"name":"web"}\n');
const pidFilePath = join(tmp, "web-server.pid");
const registryPath = join(tmp, "web-instances.json");
let capturedOptions: Record<string, unknown> | undefined;
t.after(() => {
rmSync(tmp, { recursive: true, force: true });
});
const status = await webMode.launchWebMode(
{
cwd: "/tmp/winhide-src-project",
projectSessionsDir: "/tmp/.gsd/sessions/winhide-src",
agentDir: "/tmp/.gsd/agent",
packageRoot: tmp,
},
{
initResources: () => {},
resolvePort: async () => 46001,
execPath: "/custom/node",
env: { TEST_ENV: "1" },
platform: "win32",
spawn: (_command, _args, options) => {
capturedOptions = options as Record<string, unknown>;
return {
pid: 70002,
once: () => undefined,
unref: () => {},
} as any;
},
waitForBootReady: async () => undefined,
openBrowser: () => {},
pidFilePath,
writePidFile: webMode.writePidFile,
registryPath,
stderr: { write: () => true },
},
);
assert.equal(status.ok, true, "launch should succeed");
assert.ok(capturedOptions, "spawn must have been called");
assert.equal(
capturedOptions!.windowsHide,
true,
"source-dev spawn must also include windowsHide: true (#2628)",
);
});

View file

@ -14,7 +14,7 @@ const DEFAULT_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '.
function openBrowser(url: string): void {
if (process.platform === 'win32') {
// PowerShell's Start-Process handles URLs with '&' safely; cmd /c start does not.
execFile('powershell', ['-c', `Start-Process '${url.replace(/'/g, "''")}'`], () => {})
execFile('powershell', ['-c', `Start-Process '${url.replace(/'/g, "''")}'`], { windowsHide: true }, () => {})
} else {
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open'
execFile(cmd, [url], () => {})
@ -635,6 +635,7 @@ export async function launchWebMode(
cwd: spawnSpec.cwd,
detached: true,
stdio: 'ignore',
windowsHide: true,
env,
},
)

View file

@ -153,6 +153,7 @@ export async function collectAuthoritativeAutoDashboardData(
[AUTO_DASHBOARD_MODULE_ENV]: autoModulePath,
},
maxBuffer: AUTO_DASHBOARD_MAX_BUFFER,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {

View file

@ -772,6 +772,7 @@ async function loadSessionBrowserSessionsViaChildProcess(config: BridgeRuntimeCo
GSD_SESSION_BROWSER_DIR: config.projectSessionsDir,
},
maxBuffer: 1024 * 1024,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {
@ -833,6 +834,7 @@ async function appendSessionInfoViaChildProcess(
GSD_TARGET_SESSION_NAME: name,
},
maxBuffer: 1024 * 1024,
windowsHide: true,
},
(error, _stdout, stderr) => {
if (error) {
@ -1031,6 +1033,7 @@ async function loadWorkspaceIndexViaChildProcess(basePath: string, packageRoot:
GSD_WORKSPACE_BASE: basePath,
},
maxBuffer: 1024 * 1024,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {
@ -1624,6 +1627,7 @@ export class BridgeService {
cwd: cliEntry.cwd,
env: childEnv,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
}) as SpawnedRpcChild;
this.process = child;

View file

@ -64,6 +64,7 @@ export async function collectCapturesData(projectCwdOverride?: string): Promise<
GSD_CAPTURES_BASE: projectCwd,
},
maxBuffer: CAPTURES_MAX_BUFFER,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {
@ -136,6 +137,7 @@ export async function resolveCaptureAction(request: CaptureResolveRequest, proje
GSD_CAPTURES_BASE: projectCwd,
},
maxBuffer: CAPTURES_MAX_BUFFER,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {

View file

@ -78,6 +78,7 @@ export async function collectCleanupData(projectCwdOverride?: string): Promise<C
GSD_CLEANUP_BASE: projectCwd,
},
maxBuffer: CLEANUP_MAX_BUFFER,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {
@ -170,6 +171,7 @@ export async function executeCleanup(
GSD_CLEANUP_SNAPSHOTS: JSON.stringify(pruneSnapshots),
},
maxBuffer: CLEANUP_MAX_BUFFER,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {

View file

@ -41,6 +41,7 @@ function runDoctorChild(
GSD_DOCTOR_SCOPE: scope ?? "",
},
maxBuffer: DOCTOR_MAX_BUFFER,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {

View file

@ -74,6 +74,7 @@ export async function collectExportData(
GSD_EXPORT_FORMAT: format,
},
maxBuffer: EXPORT_MAX_BUFFER,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {

View file

@ -94,6 +94,7 @@ export async function collectForensicsData(projectCwdOverride?: string): Promise
GSD_FORENSICS_BASE: projectCwd,
},
maxBuffer: FORENSICS_MAX_BUFFER,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {

View file

@ -66,6 +66,7 @@ export async function collectHistoryData(projectCwdOverride?: string): Promise<H
GSD_HISTORY_BASE: projectCwd,
},
maxBuffer: HISTORY_MAX_BUFFER,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {

View file

@ -66,6 +66,7 @@ export async function collectHooksData(projectCwdOverride?: string): Promise<Hoo
[HOOKS_MODULE_ENV]: hooksModulePath,
},
maxBuffer: HOOKS_MAX_BUFFER,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {

View file

@ -491,6 +491,7 @@ async function collectRecoveryDiagnosticsChildPayload(
GSD_RECOVERY_FORENSICS_MODULE: sessionForensicsModulePath,
},
maxBuffer: RECOVERY_DIAGNOSTICS_MAX_BUFFER,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {

View file

@ -142,6 +142,7 @@ export async function collectSettingsData(projectCwdOverride?: string): Promise<
GSD_SETTINGS_BASE: projectCwd,
},
maxBuffer: SETTINGS_MAX_BUFFER,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {

View file

@ -61,6 +61,7 @@ export async function collectSkillHealthData(projectCwdOverride?: string): Promi
GSD_SKILL_HEALTH_BASE: projectCwd,
},
maxBuffer: SKILL_HEALTH_MAX_BUFFER,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {

View file

@ -195,6 +195,7 @@ export async function executeUndo(projectCwdOverride?: string): Promise<UndoResu
GSD_UNDO_BASE: projectCwd,
},
maxBuffer: UNDO_MAX_BUFFER,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {

View file

@ -73,6 +73,7 @@ export function triggerUpdate(targetVersion?: string): boolean {
stdio: ["ignore", "ignore", "pipe"],
// Detach so the child process is not killed if the parent exits
detached: false,
windowsHide: true,
})
let stderr = ""

View file

@ -98,6 +98,7 @@ export async function collectVisualizerData(projectCwdOverride?: string): Promis
GSD_VISUALIZER_BASE: projectCwd,
},
maxBuffer: VISUALIZER_MAX_BUFFER,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {