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
browse daemon running.
browse goto https://example.com.
- Observe 2+ console windows flash and steal focus. An elevated
Win32_ProcessStartTrace shows the conhost.exe parents are node (launcher), git, and icacls.
Environment
- Windows 11, gstack @
920a13a, bun 1.3.11, Node from C:\Program Files\nodejs.
- Default headless path. (Separately,
chrome-headless-shell.exe is a console-subsystem binary; launching full chromium with --headless=new avoids that one too, but it was not the cause of these particular flickers.)
Windows:
browsecold-start flashes focus-stealing console windows (Bun.spawnSyncignoreswindowsHide)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 effectivewindowsHide.Root cause (verified with an elevated
Win32_ProcessStartTrace)Tracing process creation during a cold
browse gotoshows the flashingconhost.exewindows are parented to:node.exe(the detach launcher)browse.exebrowse/src/cli.tsstartServer()—Bun.spawnSync(['node','-e',launcherCode], …)git.exe(rev-parse --show-toplevel)browse.exebrowse/src/config.tsgetGitRoot()andbrowse/src/find-browse.ts—Bun.spawnSync(['git', …])icacls.exe×2nodebrowse/src/file-permissions.tsrestrictFilePermissions/restrictDirectoryPermissions—execFileSync('icacls', …, { stdio:'ignore' })(nowindowsHide)The key finding
Bun.spawnSyncdoes not honor thewindowsHideoption on Windows (tested on bun 1.3.11). AddingwindowsHide: trueto aBun.spawnSync(...)call has no effect — the child still allocates a console window.Node's
child_process(spawn/spawnSync/execFileSync) does honorwindowsHide. (Confirmed in the same trace: the detached daemonnode, spawned via Node'schild_process.spawn({ detached:true, windowsHide:true })insidelauncherCode, did not get a console window — only theBun.spawnSync-spawned launcher and git did.)So the fix is: for Windows console spawns, use Node's
child_processwithwindowsHide:truerather thanBun.spawnSync, and addwindowsHide:trueto theexecFileSync('icacls', …)calls.Suggested fix
The detached daemon spawn just needs the
windowsHide:trueadded to its options object (Node honors it).Repro
browsedaemon running.browse goto https://example.com.Win32_ProcessStartTraceshows theconhost.exeparents arenode(launcher),git, andicacls.Environment
920a13a, bun 1.3.11, Node fromC:\Program Files\nodejs.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.)