From af776a8aaed6e352d545de6ba06958f070a06fa6 Mon Sep 17 00:00:00 2001 From: Jerry Nichols Date: Wed, 12 Aug 2026 15:37:28 -0500 Subject: [PATCH] fix(browse): forward windowsHide through the Bun polyfill spawn shims The Node fallback shim accepts a Bun.spawn options object and forwards only stdio, env and cwd to child_process.spawn. windowsHide is dropped, and because Node defaults it to false while Bun.spawn hides the console window, the omission inverts the behavior on the one platform the shim exists to support. Symptom: the terminal-agent respawn in server.ts (60s watchdog ticker) pops a visible bun.exe console window on Windows every time it fires, so the window keeps coming back with no scheduled task or startup entry behind it. stdio:'ignore' silences the child's output but does not suppress its window. Both shims now forward the option and default it to true, matching the Bun API being emulated; an explicit windowsHide:false still passes through. spawnTerminalAgent also sets it explicitly at the call site. Tests: three cases in browse/test/bun-polyfill.test.ts assert the default for spawn and spawnSync and that an explicit false is honored. Each was confirmed to fail against the unpatched shim. Drive-by, required to run the suite at all on Windows: the tests interpolated an absolute path into a JS string literal, so backslashes were consumed as escapes and every require() failed with MODULE_NOT_FOUND. The path is now normalized to forward slashes. On Windows this file went from 0/4 passing to 7/7. --- browse/src/bun-polyfill.cjs | 9 +++++ browse/src/terminal-agent-control.ts | 4 +++ browse/test/bun-polyfill.test.ts | 54 +++++++++++++++++++++++++--- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/browse/src/bun-polyfill.cjs b/browse/src/bun-polyfill.cjs index e0ada11b3a..654f4540cc 100644 --- a/browse/src/bun-polyfill.cjs +++ b/browse/src/bun-polyfill.cjs @@ -75,6 +75,10 @@ globalThis.Bun = { timeout: options.timeout, env: options.env, cwd: options.cwd, + // Node defaults windowsHide to false; Bun.spawn hides the console + // window. Without this the shim silently inverts the behavior on the + // one platform it exists to serve. See the spawn() note below. + windowsHide: options.windowsHide !== false, }); return { @@ -91,6 +95,11 @@ globalThis.Bun = { stdio, env: options.env, cwd: options.cwd, + // stdio:'ignore' silences a child's output but does not suppress its + // console window on Windows. The terminal-agent respawn (server.ts + // watchdog, 60s ticker) therefore popped a visible bun.exe window on + // every respawn until this was forwarded. + windowsHide: options.windowsHide !== false, }); return { diff --git a/browse/src/terminal-agent-control.ts b/browse/src/terminal-agent-control.ts index 094ba668fa..9d96a73a6b 100644 --- a/browse/src/terminal-agent-control.ts +++ b/browse/src/terminal-agent-control.ts @@ -77,6 +77,10 @@ export function spawnTerminalAgent(opts: { ...(opts.extraEnv || {}), }, stdio: ['ignore', 'ignore', 'ignore'], + // Explicit for the Node fallback path (dist/bun-polyfill.cjs), where the + // host default is the opposite of Bun's. A visible console window on every + // watchdog respawn is the symptom when this is missing. + windowsHide: true, }); proc.unref?.(); return proc.pid ?? null; diff --git a/browse/test/bun-polyfill.test.ts b/browse/test/bun-polyfill.test.ts index 7ca25dfabe..b73bbbbcd8 100644 --- a/browse/test/bun-polyfill.test.ts +++ b/browse/test/bun-polyfill.test.ts @@ -3,6 +3,9 @@ import * as path from 'path'; // Load the polyfill into a fresh object (don't clobber globalThis.Bun) const polyfillPath = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs'); +// Forward slashes so the path survives interpolation into a JS string literal +// on Windows, which is the platform this polyfill exists for. +const requirePath = polyfillPath.replace(/\\/g, '/'); describe('bun-polyfill', () => { // We test the polyfill by requiring it in a subprocess under Node.js @@ -10,7 +13,7 @@ describe('bun-polyfill', () => { test('Bun.sleep resolves after delay', async () => { const result = Bun.spawnSync(['node', '-e', ` - require('${polyfillPath}'); + require('${requirePath}'); (async () => { const start = Date.now(); await Bun.sleep(50); @@ -24,7 +27,7 @@ describe('bun-polyfill', () => { test('Bun.spawnSync runs a command and returns stdout', () => { const result = Bun.spawnSync(['node', '-e', ` - require('${polyfillPath}'); + require('${requirePath}'); const r = Bun.spawnSync(['echo', 'hello'], { stdout: 'pipe' }); console.log(r.stdout.toString().trim()); console.log('exit:' + r.exitCode); @@ -36,7 +39,7 @@ describe('bun-polyfill', () => { test('Bun.spawn launches a process with pid', async () => { const result = Bun.spawnSync(['node', '-e', ` - require('${polyfillPath}'); + require('${requirePath}'); const p = Bun.spawn(['echo', 'test'], { stdio: ['pipe', 'pipe', 'pipe'] }); console.log(typeof p.pid === 'number' ? 'HAS_PID' : 'NO_PID'); console.log(typeof p.kill === 'function' ? 'HAS_KILL' : 'NO_KILL'); @@ -48,9 +51,52 @@ describe('bun-polyfill', () => { expect(lines[2]).toBe('HAS_UNREF'); }); + // windowsHide is the one option where Node's default is the opposite of + // Bun's: Node shows the child's console window, Bun hides it. Dropping it + // in translation makes every spawned child pop a window on Windows, which + // is the platform this whole file exists for. Both shims are covered. + test('Bun.spawn defaults windowsHide to true', () => { + const result = Bun.spawnSync(['node', '-e', ` + const cp = require('child_process'); + const orig = cp.spawn; + let seen; + cp.spawn = (c, a, o) => { seen = o; return orig(c, a, o); }; + require('${requirePath}'); + Bun.spawn(['node', '-e', ''], { stdio: ['ignore', 'ignore', 'ignore'] }); + console.log('windowsHide:' + seen.windowsHide); + `], { stdout: 'pipe', stderr: 'pipe' }); + expect(result.stdout.toString().trim()).toBe('windowsHide:true'); + }); + + test('Bun.spawnSync defaults windowsHide to true', () => { + const result = Bun.spawnSync(['node', '-e', ` + const cp = require('child_process'); + const orig = cp.spawnSync; + let seen; + cp.spawnSync = (c, a, o) => { seen = o; return orig(c, a, o); }; + require('${requirePath}'); + Bun.spawnSync(['node', '-e', '']); + console.log('windowsHide:' + seen.windowsHide); + `], { stdout: 'pipe', stderr: 'pipe' }); + expect(result.stdout.toString().trim()).toBe('windowsHide:true'); + }); + + test('an explicit windowsHide:false is honored', () => { + const result = Bun.spawnSync(['node', '-e', ` + const cp = require('child_process'); + const orig = cp.spawn; + let seen; + cp.spawn = (c, a, o) => { seen = o; return orig(c, a, o); }; + require('${requirePath}'); + Bun.spawn(['node', '-e', ''], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false }); + console.log('windowsHide:' + seen.windowsHide); + `], { stdout: 'pipe', stderr: 'pipe' }); + expect(result.stdout.toString().trim()).toBe('windowsHide:false'); + }); + test('Bun.serve creates an HTTP server that responds', async () => { const result = Bun.spawnSync(['node', '-e', ` - require('${polyfillPath}'); + require('${requirePath}'); const server = Bun.serve({ port: 0, // Note: polyfill uses port directly, so we pick one hostname: '127.0.0.1',