Windows: browse cold-start flashes focus-stealing console windows (Bun.spawnSync ignores windowsHide)
- Dominant language
- TypeScript
- Stars
- 133k
- Forks
- 19.9k
- Avg merge
- 18h 46m
- Merged PRs (30d)
- 26
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
```diff
# 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
1. Windows 11, no `browse` daemon running.
2. `browse goto https://example.com`.
3. 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.)
Contributor guide
Assessment
This issue has not been assessed yet.