Repository navigation
Windows: browse cold-start flashes focus-stealing console windows (Bun.spawnSync ignores windowsHide) #1784
Description
Windows: browse cold-start flashes focus-stealing console windows (Bun.spawnSync ignores windowsHide)
Summary
On Windows 11, every cold start of browse (i.e. when no healthy daemon is already running) briefly flashes 2+ focus-stealing console windows on the interactive desktop. They steal foreground focus and interrupt typing in other apps. The cause is several console-subsystem child processes spawned without an effective windowsHide.
Root cause (verified with an elevated Win32_ProcessStartTrace)
Tracing process creation during a cold browse goto shows the flashing conhost.exe windows are parented to:
| spawned process | spawned by | call site |
|---|---|---|
node.exe (the detach launcher) |
browse.exe |
browse/src/cli.ts startServer() — Bun.spawnSync(['node','-e',launcherCode], …) |
git.exe (rev-parse --show-toplevel) |
browse.exe |
browse/src/config.ts getGitRoot() and browse/src/find-browse.ts — Bun.spawnSync(['git', …]) |
icacls.exe ×2 |
daemon node |
browse/src/file-permissions.ts restrictFilePermissions / restrictDirectoryPermissions — execFileSync('icacls', …, { stdio:'ignore' }) (no windowsHide) |
The key finding
Bun.spawnSync does not honor the windowsHide option on Windows (tested on bun 1.3.11). Adding windowsHide: true to a Bun.spawnSync(...) call has no effect — the child still allocates a console window.
Node's child_process (spawn / spawnSync / execFileSync) does honor windowsHide. (Confirmed in the same trace: the detached daemon node, spawned via Node's child_process.spawn({ detached:true, windowsHide:true }) inside launcherCode, did not get a console window — only the Bun.spawnSync-spawned launcher and git did.)
So the fix is: for Windows console spawns, use Node's child_process with windowsHide:true rather than Bun.spawnSync, and add windowsHide:true to the execFileSync('icacls', …) calls.
Suggested fix
# browse/src/cli.ts
-import { spawn as nodeSpawn } from 'child_process';
+import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from 'child_process';
@@ startServer() (Windows branch)
- `{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 ignores windowsHide on Windows -> the launcher node flashes a
+ // console. Node's spawnSync honors it.
+ nodeSpawnSync('node', ['-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true });
# browse/src/config.ts (and browse/src/find-browse.ts likewise)
+import { execFileSync } from 'child_process';
- 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() || null;
+ // Node's execFileSync honors windowsHide; Bun.spawnSync does not.
+ const out = execFileSync('git', ['rev-parse', '--show-toplevel'],
+ { encoding:'utf-8', timeout:2_000, windowsHide:true, stdio:['ignore','pipe','ignore'] });
+ return out.trim() || null;
# browse/src/file-permissions.ts (both execFileSync('icacls', …) calls)
- { stdio: 'ignore' },
+ { stdio: 'ignore', windowsHide: true },The detached daemon spawn just needs the windowsHide:true added to its options object (Node honors it).
Repro
- Windows 11, no
browsedaemon running. browse goto https://example.com.- Observe 2+ console windows flash and steal focus. An elevated
Win32_ProcessStartTraceshows theconhost.exeparents arenode(launcher),git, andicacls.
Environment
- Windows 11, gstack @
920a13a, bun 1.3.11, Node fromC:\Program Files\nodejs. - Default headless path. (Separately,
chrome-headless-shell.exeis a console-subsystem binary; launching full chromium with--headless=newavoids that one too, but it was not the cause of these particular flickers.)
Activity
mvanhorn commentedon Jun 4, 2026
Implemented in #1864.
time-attack commentedon Jul 15, 2026
@kaiwulff Browse startup still has child-process paths that can show focus-stealing consoles, including find-browse.
capy-ai commentedon Oct 2, 2026
Believed fixed on main (v1.91.13.0): every spawn site in
browse/srcnow passeswindowsHide(enforced by thebrowse/test/windows-spawn-hide.test.tssweep, passing on the Windows CI lane), the Node polyfill forwards it, and the terminal-agent watchdog fix withbrowse/test/terminal-agent-watchdog.test.tsshipped in v1.67.0.0. We can't prove console suppression or agent survival on a real Windows desktop from CI. Could you confirm on v1.91.13.0 or later? We'll close once you do, or reopen the investigation if it still happens.kaiwulff commentedon Oct 3, 2026
Confirmed fixed on a real Windows 11 desktop. Clean, unpatched build of main @ 74512c2 (v1.91.15.0), bun 1.3.11, run from Claude Code's Bash tool. A watcher polling every 2 ms logged every new visible window and every foreground change. Positive control: a detached node spawning
cmdwithoutwindowsHide→ Windows Terminal window plus focus steal. Same spawn withwindowsHide→ nothing. Threebrowse gotocold starts and twobrowse stopruns → zero console windows, no focus steal. Fine to close. Thanks!capy-ai commentedon Oct 3, 2026
Every browse spawn site now passes
windowsHide(enforced bybrowse/test/windows-spawn-hide.test.ts), and the reporter confirmed zero console windows and no focus steal on a real Windows 11 desktop with v1.91.15.0.Fixed in v1.91.13.0 (#3013).
If you still see this on the latest gstack, please reopen with details.