Fix Windows browse daemon cold start

This commit is contained in:
Catfish-75 2026-06-03 07:52:03 +03:00
parent 6324160dcc
commit 3aa2846738
4 changed files with 115 additions and 12 deletions

View File

@ -239,7 +239,17 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
`{detached:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
`${extraEnvStr})}).unref()`;
spawnSync('node', ['-e', launcherCode], { stdio: 'ignore' });
const launcher = Bun.spawnSync(['node', '-e', launcherCode], {
stdout: 'pipe',
stderr: 'pipe',
timeout: 5000,
});
const launcherExit = (launcher as any).exitCode ?? (launcher as any).status ?? 0;
if (launcherExit !== 0) {
const stderr = launcher.stderr?.toString().trim();
const stdout = launcher.stdout?.toString().trim();
throw new Error(`Windows launcher failed${stderr || stdout ? `:\n${stderr || stdout}` : ''}`);
}
} else {
// macOS/Linux: Bun.spawn().unref() only removes the child from Bun's event
// loop — it does NOT call setsid(), so the spawned server stays in the

View File

@ -129,7 +129,7 @@ let activeShutdown: ((code?: number) => Promise<void>) | null = null;
// silently weakened by misconfiguration.
function sanitizeAuthToken(raw: string | undefined): string | null {
if (!raw) return null;
const stripped = raw.replace(/[\s -]/g, '');
const stripped = raw.replace(/[\s\u00a0\u200b-\u200d\ufeff]/g, '');
if (stripped.length < 16) return null;
return stripped;
}

View File

@ -19,6 +19,54 @@ import * as path from 'path';
import { safeUnlink, safeKill, isProcessAlive } from './error-handling';
import { writeSecureFile, mkdirSecure } from './file-permissions';
export interface BunSpawnCommand {
command: string;
argsPrefix: string[];
}
function envPath(env: Record<string, string | undefined>): string {
return env.PATH || env.Path || env.path || '';
}
/**
* Resolve a Bun executable suitable for a detached child spawn.
*
* On Windows, npm installs expose `bun` as a POSIX shell shim plus `bun.cmd`.
* Node/Bun process spawning does not reliably execute those shims directly,
* so prefer the real `node_modules/bun/bin/bun.exe` beside them.
*/
export function resolveBunSpawnCommand(
env: Record<string, string | undefined> = process.env,
platform: NodeJS.Platform = process.platform,
execPath: string = process.execPath,
): BunSpawnCommand | null {
if (platform !== 'win32') return { command: 'bun', argsPrefix: [] };
if (execPath && path.basename(execPath).toLowerCase() === 'bun.exe' && fs.existsSync(execPath)) {
return { command: execPath, argsPrefix: [] };
}
const dirs = envPath(env).split(path.delimiter).filter(Boolean);
for (const dir of dirs) {
const candidates = [
path.join(dir, 'bun.exe'),
path.join(dir, 'node_modules', 'bun', 'bin', 'bun.exe'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return { command: candidate, argsPrefix: [] };
}
for (const shim of ['bun.cmd', 'bun.bat']) {
const shimPath = path.join(dir, shim);
if (fs.existsSync(shimPath)) {
return { command: 'cmd.exe', argsPrefix: ['/d', '/s', '/c', `"${shimPath}"`] };
}
}
}
return null;
}
/**
* Locate the terminal-agent script on disk. In dev (cli.ts running via
* `bun run`), it lives next to this file in browse/src. In a compiled
@ -68,16 +116,24 @@ export function spawnTerminalAgent(opts: {
}
const script = opts.scriptPath || resolveTerminalAgentScript();
if (!script || !fs.existsSync(script)) return null;
const proc = (Bun as any).spawn(['bun', 'run', script], {
cwd: opts.cwd || process.cwd(),
env: {
...process.env,
BROWSE_STATE_FILE: opts.stateFile,
BROWSE_SERVER_PORT: String(opts.serverPort),
...(opts.extraEnv || {}),
},
stdio: ['ignore', 'ignore', 'ignore'],
});
const bun = resolveBunSpawnCommand();
if (!bun) return null;
let proc: any;
try {
proc = (Bun as any).spawn([bun.command, ...bun.argsPrefix, 'run', script], {
cwd: opts.cwd || process.cwd(),
env: {
...process.env,
BROWSE_STATE_FILE: opts.stateFile,
BROWSE_SERVER_PORT: String(opts.serverPort),
...(opts.extraEnv || {}),
},
stdio: ['ignore', 'ignore', 'ignore'],
});
} catch (err: any) {
if (err?.code === 'ENOENT' || err?.code === 'EACCES') return null;
throw err;
}
proc.unref?.();
return proc.pid ?? null;
}

View File

@ -1,5 +1,6 @@
import { describe, test, expect } from 'bun:test';
import { resolveConfig, ensureStateDir, readVersionHash, getGitRoot, getRemoteSlug, resolveGstackHome, resolveChromiumProfile, cleanSingletonLocks } from '../src/config';
import { resolveBunSpawnCommand } from '../src/terminal-agent-control';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
@ -342,6 +343,42 @@ describe('cli command dispatch', () => {
expect(cliSource).toContain('Starting server in headed mode');
expect(cliSource).toContain('Starting server with proxy');
});
test('Windows launcher uses Bun.spawnSync for compiled CLI compatibility', () => {
expect(cliSource).toContain("Bun.spawnSync(['node', '-e', launcherCode]");
expect(cliSource).not.toContain("spawnSync('node', ['-e', launcherCode]");
});
});
describe('server source portability', () => {
const serverSource = fs.readFileSync(path.resolve(__dirname, '../src/server.ts'), 'utf-8');
test('auth-token whitespace regex uses ASCII escapes for Node bundle safety', () => {
expect(serverSource).toContain('raw.replace(/[\\s\\u00a0\\u200b-\\u200d\\ufeff]/g');
expect(serverSource).not.toContain('raw.replace(/[\\s ');
});
});
describe('terminal agent spawn command', () => {
test('Windows npm bun shim resolves to real bun.exe', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-bun-shim-'));
const bunExe = path.join(tmpDir, 'node_modules', 'bun', 'bin', 'bun.exe');
fs.mkdirSync(path.dirname(bunExe), { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'bun.cmd'), '@echo off\n');
fs.writeFileSync(bunExe, '');
try {
const result = resolveBunSpawnCommand({ PATH: tmpDir }, 'win32', 'C:\\Windows\\System32\\node.exe');
expect(result).toEqual({ command: bunExe, argsPrefix: [] });
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('Windows missing bun is non-fatal for terminal-agent spawn', () => {
const result = resolveBunSpawnCommand({ PATH: '' }, 'win32', 'C:\\Windows\\System32\\node.exe');
expect(result).toBeNull();
});
});
describe('write command dispatch', () => {