fix: Windows browse — health-check-first ensureServer, detached startServer, Windows process mgmt (v0.11.11.0) (#431)

* fix: Windows browse — health-check-first ensureServer, detached startServer, Windows process mgmt

Three compounding bugs made browse completely broken on Windows:

1. Bun.spawn().unref() doesn't truly detach on Windows — server dies when
   CLI exits. Fix: use Node's child_process.spawn with { detached: true }
   via a launcher script. Credit: PR #191 by @fqueiro for the approach.

2. process.kill(pid, 0) is broken in Bun's compiled binary on Windows —
   ensureServer() never reaches the health check. Fix: restructure to
   health-check-first (HTTP is definitive proof on all platforms). Extract
   isServerHealthy() helper for DRY (4 call sites).

3. Windows process management: isProcessAlive() falls back to tasklist,
   killServer() uses taskkill /T /F (kills process tree including Chromium),
   cleanupLegacyState() skips on Windows (no /tmp, no ps).

Also: hard-fail on Windows if server-node.mjs is missing instead of
silently falling back to the known-broken Bun path.

Fixes #342.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: disable Chromium sandbox on Windows

Chromium's sandbox fails when the server is spawned through the
Bun→Node process chain on Windows (GitHub #276). Disable
chromiumSandbox on Windows at both launch sites (headless + headed).

Safe: local daemon browsing user-specified URLs, Playwright docs
recommend disabling in CI/container environments.

Fixes #276.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: startup error log + Windows exit handler for browse server

On Windows, the CLI can't capture stderr from the server (stdio: 'ignore'
required for process detachment). Write startup errors to
.gstack/browse-startup-error.log so the CLI can report them on timeout.

Also add process.on('exit') handler on Windows as defense-in-depth for
state file cleanup (primary mechanism is CLI's stale-state detection).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add isServerHealthy + startup error log tests

Tests for the new cross-platform health check helper (isServerHealthy)
that replaces PID-based liveness checks in all polling loops. Covers
healthy, unhealthy, unreachable, and error response cases.

Also tests the startup error log write/read format used on Windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.11.11.0)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: sync ARCHITECTURE.md with health-check-first ensureServer

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-24 00:38:10 -07:00
committed by GitHub
parent dc5e0538e5
commit 3501f5dd03
8 changed files with 212 additions and 43 deletions

View File

@@ -248,3 +248,69 @@ describe('version mismatch detection', () => {
expect(shouldRestart).toBe(false);
});
});
describe('isServerHealthy', () => {
const { isServerHealthy } = require('../src/cli');
const http = require('http');
test('returns true for a healthy server', async () => {
const server = http.createServer((_req: any, res: any) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'healthy' }));
});
await new Promise<void>(resolve => server.listen(0, resolve));
const port = server.address().port;
try {
expect(await isServerHealthy(port)).toBe(true);
} finally {
server.close();
}
});
test('returns false for an unhealthy server', async () => {
const server = http.createServer((_req: any, res: any) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'unhealthy' }));
});
await new Promise<void>(resolve => server.listen(0, resolve));
const port = server.address().port;
try {
expect(await isServerHealthy(port)).toBe(false);
} finally {
server.close();
}
});
test('returns false when server is not running', async () => {
// Use a port that's almost certainly not in use
expect(await isServerHealthy(59999)).toBe(false);
});
test('returns false on non-200 response', async () => {
const server = http.createServer((_req: any, res: any) => {
res.writeHead(500);
res.end('Internal Server Error');
});
await new Promise<void>(resolve => server.listen(0, resolve));
const port = server.address().port;
try {
expect(await isServerHealthy(port)).toBe(false);
} finally {
server.close();
}
});
});
describe('startup error log', () => {
test('write and read error log', () => {
const tmpDir = path.join(os.tmpdir(), `browse-error-log-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
const errorLogPath = path.join(tmpDir, 'browse-startup-error.log');
const errorMsg = 'Cannot find module playwright';
fs.writeFileSync(errorLogPath, `2026-03-23T00:00:00.000Z ${errorMsg}\n`);
const content = fs.readFileSync(errorLogPath, 'utf-8').trim();
expect(content).toContain(errorMsg);
expect(content).toMatch(/^\d{4}-\d{2}-\d{2}T/); // ISO timestamp prefix
fs.rmSync(tmpDir, { recursive: true, force: true });
});
});