mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-20 19:29:56 +08:00
Merge remote-tracking branch 'origin/main' into garrytan/plan-tune-skill
This commit is contained in:
@@ -442,7 +442,7 @@ State persists between calls (cookies, tabs, login sessions).
|
||||
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
B=""
|
||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
|
||||
[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse
|
||||
[ -z "$B" ] && B="$HOME/.claude/skills/gstack/browse/dist/browse"
|
||||
if [ -x "$B" ]; then
|
||||
echo "READY: $B"
|
||||
else
|
||||
|
||||
@@ -14,13 +14,19 @@ DIST_DIR="$GSTACK_DIR/browse/dist"
|
||||
echo "Building Node-compatible server bundle..."
|
||||
|
||||
# Step 1: Transpile server.ts to a single .mjs bundle (externalize runtime deps)
|
||||
#
|
||||
# Externalize packages with native addons, dynamic imports, or runtime resolution.
|
||||
# If you add a new dependency that uses `await import()` or has a .node addon,
|
||||
# add it here. Otherwise `bun build --outfile` will fail with
|
||||
# "cannot write multiple output files without an output directory".
|
||||
bun build "$SRC_DIR/server.ts" \
|
||||
--target=node \
|
||||
--outfile "$DIST_DIR/server-node.mjs" \
|
||||
--external playwright \
|
||||
--external playwright-core \
|
||||
--external diff \
|
||||
--external "bun:sqlite"
|
||||
--external "bun:sqlite" \
|
||||
--external "@ngrok/ngrok"
|
||||
|
||||
# Step 2: Post-process
|
||||
# Replace import.meta.dir with a resolvable reference
|
||||
|
||||
@@ -72,6 +72,12 @@ export class BrowserManager {
|
||||
private connectionMode: 'launched' | 'headed' = 'launched';
|
||||
private intentionalDisconnect = false;
|
||||
|
||||
// Called when the headed browser disconnects without intentional teardown
|
||||
// (user closed the window). Wired up by server.ts to run full cleanup
|
||||
// (sidebar-agent, state file, profile locks) before exiting with code 2.
|
||||
// Returns void or a Promise; rejections are caught and fall back to exit(2).
|
||||
public onDisconnect: (() => void | Promise<void>) | null = null;
|
||||
|
||||
getConnectionMode(): 'launched' | 'headed' { return this.connectionMode; }
|
||||
|
||||
// ─── Watch Mode Methods ─────────────────────────────────
|
||||
@@ -467,13 +473,32 @@ export class BrowserManager {
|
||||
await this.newTab();
|
||||
}
|
||||
|
||||
// Browser disconnect handler — exit code 2 distinguishes from crashes (1)
|
||||
// Browser disconnect handler — exit code 2 distinguishes from crashes (1).
|
||||
// Calls onDisconnect() to trigger full shutdown (kill sidebar-agent, save
|
||||
// session, clean profile locks + state file) before exit. Falls back to
|
||||
// direct process.exit(2) if no callback is wired up, or if the callback
|
||||
// throws/rejects — never leave the process running with a dead browser.
|
||||
if (this.browser) {
|
||||
this.browser.on('disconnected', () => {
|
||||
if (this.intentionalDisconnect) return;
|
||||
console.error('[browse] Real browser disconnected (user closed or crashed).');
|
||||
console.error('[browse] Run `$B connect` to reconnect.');
|
||||
process.exit(2);
|
||||
if (!this.onDisconnect) {
|
||||
process.exit(2);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = this.onDisconnect();
|
||||
if (result && typeof (result as Promise<void>).catch === 'function') {
|
||||
(result as Promise<void>).catch((err) => {
|
||||
console.error('[browse] onDisconnect rejected:', err);
|
||||
process.exit(2);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[browse] onDisconnect threw:', err);
|
||||
process.exit(2);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -210,12 +210,20 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
|
||||
let proc: any = null;
|
||||
|
||||
// Allow the caller to opt out of the parent-process watchdog by setting
|
||||
// BROWSE_PARENT_PID=0 in the environment. Useful for CI, non-interactive
|
||||
// shells, and short-lived Bash invocations that need the server to outlive
|
||||
// the spawning CLI. Defaults to the current process PID (watchdog active).
|
||||
// Parse as int so stray whitespace ("0\n") still opts out — matches the
|
||||
// server's own parseInt at server.ts:760.
|
||||
const parentPid = parseInt(process.env.BROWSE_PARENT_PID || '', 10) === 0 ? '0' : String(process.pid);
|
||||
|
||||
if (IS_WINDOWS && NODE_SERVER_SCRIPT) {
|
||||
// Windows: Bun.spawn() + proc.unref() doesn't truly detach on Windows —
|
||||
// when the CLI exits, the server dies with it. Use Node's child_process.spawn
|
||||
// with { detached: true } instead, which is the gold standard for Windows
|
||||
// process independence. Credit: PR #191 by @fqueiro.
|
||||
const extraEnvStr = JSON.stringify({ BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: String(process.pid), ...(extraEnv || {}) });
|
||||
const extraEnvStr = JSON.stringify({ BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...(extraEnv || {}) });
|
||||
const launcherCode =
|
||||
`const{spawn}=require('child_process');` +
|
||||
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
|
||||
@@ -226,7 +234,7 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
// macOS/Linux: Bun.spawn + unref works correctly
|
||||
proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: String(process.pid), ...extraEnv },
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...extraEnv },
|
||||
});
|
||||
proc.unref();
|
||||
}
|
||||
@@ -826,12 +834,12 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
BROWSE_HEADED: '1',
|
||||
BROWSE_PORT: '34567',
|
||||
BROWSE_SIDEBAR_CHAT: '1',
|
||||
// Disable parent-process watchdog: the user controls the headed browser
|
||||
// window lifecycle. The CLI exits immediately after connect, so watching
|
||||
// it would kill the server ~15s later. Cleanup happens via browser
|
||||
// disconnect event or $B disconnect.
|
||||
BROWSE_PARENT_PID: '0',
|
||||
};
|
||||
// If parent explicitly set BROWSE_PARENT_PID=0 (pair-agent disabling
|
||||
// self-termination), pass it through so startServer doesn't override it.
|
||||
if (process.env.BROWSE_PARENT_PID === '0') {
|
||||
serverEnv.BROWSE_PARENT_PID = '0';
|
||||
}
|
||||
const newState = await startServer(serverEnv);
|
||||
|
||||
// Print connected status
|
||||
|
||||
@@ -757,8 +757,16 @@ const idleCheckInterval = setInterval(() => {
|
||||
// server can become an orphan — keeping chrome-headless-shell alive and
|
||||
// causing console-window flicker on Windows. Poll the parent PID every 15s
|
||||
// and self-terminate if it is gone.
|
||||
//
|
||||
// Headed mode (BROWSE_HEADED=1 or BROWSE_PARENT_PID=0): The user controls
|
||||
// the browser window lifecycle. The CLI exits immediately after connect,
|
||||
// so the watchdog would kill the server prematurely. Disabled in both cases
|
||||
// as defense-in-depth — the CLI sets PID=0 for headed mode, and the server
|
||||
// also checks BROWSE_HEADED in case a future launcher forgets.
|
||||
// Cleanup happens via browser disconnect event or $B disconnect.
|
||||
const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10);
|
||||
if (BROWSE_PARENT_PID > 0) {
|
||||
const IS_HEADED_WATCHDOG = process.env.BROWSE_HEADED === '1';
|
||||
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
|
||||
setInterval(() => {
|
||||
try {
|
||||
process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent
|
||||
@@ -767,6 +775,10 @@ if (BROWSE_PARENT_PID > 0) {
|
||||
shutdown();
|
||||
}
|
||||
}, 15_000);
|
||||
} else if (IS_HEADED_WATCHDOG) {
|
||||
console.log('[browse] Parent-process watchdog disabled (headed mode)');
|
||||
} else if (BROWSE_PARENT_PID === 0) {
|
||||
console.log('[browse] Parent-process watchdog disabled (BROWSE_PARENT_PID=0)');
|
||||
}
|
||||
|
||||
// ─── Command Sets (from commands.ts — single source of truth) ───
|
||||
@@ -793,6 +805,10 @@ function emitInspectorEvent(event: any): void {
|
||||
|
||||
// ─── Server ────────────────────────────────────────────────────
|
||||
const browserManager = new BrowserManager();
|
||||
// When the user closes the headed browser window, run full cleanup
|
||||
// (kill sidebar-agent, save session, remove profile locks, delete state file)
|
||||
// before exiting with code 2. Exit code 2 distinguishes user-close from crashes (1).
|
||||
browserManager.onDisconnect = () => shutdown(2);
|
||||
let isShuttingDown = false;
|
||||
|
||||
// Test if a port is available by binding and immediately releasing.
|
||||
@@ -1180,7 +1196,7 @@ async function handleCommand(body: any, tokenInfo?: TokenInfo | null): Promise<R
|
||||
});
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
async function shutdown(exitCode: number = 0) {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
|
||||
@@ -1221,12 +1237,15 @@ async function shutdown() {
|
||||
// Clean up state file
|
||||
safeUnlinkQuiet(config.stateFile);
|
||||
|
||||
process.exit(0);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
// Handle signals
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
// Node passes the signal name (e.g. 'SIGTERM') as the first arg to listeners.
|
||||
// Wrap so shutdown() receives no args — otherwise the string gets passed as
|
||||
// exitCode and process.exit() coerces it to NaN, exiting with code 1 instead of 0.
|
||||
process.on('SIGTERM', () => shutdown());
|
||||
process.on('SIGINT', () => shutdown());
|
||||
// Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths.
|
||||
// Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check.
|
||||
if (process.platform === 'win32') {
|
||||
|
||||
28
browse/test/build.test.ts
Normal file
28
browse/test/build.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const DIST_DIR = path.resolve(__dirname, '..', 'dist');
|
||||
const SERVER_NODE = path.join(DIST_DIR, 'server-node.mjs');
|
||||
|
||||
describe('build: server-node.mjs', () => {
|
||||
test('passes node --check if present', () => {
|
||||
if (!fs.existsSync(SERVER_NODE)) {
|
||||
// browse/dist is gitignored; no build has run in this checkout.
|
||||
// Skip rather than fail so plain `bun test` without a prior build passes.
|
||||
return;
|
||||
}
|
||||
expect(() => execSync(`node --check ${SERVER_NODE}`, { stdio: 'pipe' })).not.toThrow();
|
||||
});
|
||||
|
||||
test('does not inline @ngrok/ngrok (must be external)', () => {
|
||||
if (!fs.existsSync(SERVER_NODE)) return;
|
||||
const bundle = fs.readFileSync(SERVER_NODE, 'utf-8');
|
||||
// Dynamic imports of externalized packages show up as string literals in the bundle,
|
||||
// not as inlined module code. The heuristic: ngrok's native binding loader would
|
||||
// reference its own internals. If any ngrok internal identifier appears, the module
|
||||
// got inlined despite the --external flag.
|
||||
expect(bundle).not.toMatch(/ngrok_napi|ngrokNapi|@ngrok\/ngrok-darwin|@ngrok\/ngrok-linux|@ngrok\/ngrok-win32/);
|
||||
});
|
||||
});
|
||||
147
browse/test/watchdog.test.ts
Normal file
147
browse/test/watchdog.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, test, expect, afterEach } from 'bun:test';
|
||||
import { spawn, type Subprocess } from 'bun';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
|
||||
// End-to-end regression tests for the parent-process watchdog in server.ts.
|
||||
// Proves three invariants that the v0.18.1.0 fix depends on:
|
||||
//
|
||||
// 1. BROWSE_PARENT_PID=0 disables the watchdog (opt-in used by CI and pair-agent).
|
||||
// 2. BROWSE_HEADED=1 disables the watchdog (server-side defense-in-depth).
|
||||
// 3. Default headless mode still kills the server when its parent dies
|
||||
// (the original orphan-prevention must keep working).
|
||||
//
|
||||
// Each test spawns the real server.ts, not a mock. Tests 1 and 2 verify the
|
||||
// code path via stdout log line (fast). Test 3 waits for the watchdog's 15s
|
||||
// poll cycle to actually fire (slow — ~25s).
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SERVER_SCRIPT = path.join(ROOT, 'src', 'server.ts');
|
||||
|
||||
let tmpDir: string;
|
||||
let serverProc: Subprocess | null = null;
|
||||
let parentProc: Subprocess | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
// Kill any survivors so subsequent tests get a clean slate.
|
||||
try { parentProc?.kill('SIGKILL'); } catch {}
|
||||
try { serverProc?.kill('SIGKILL'); } catch {}
|
||||
// Give processes a moment to exit before tmpDir cleanup.
|
||||
await Bun.sleep(100);
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
parentProc = null;
|
||||
serverProc = null;
|
||||
});
|
||||
|
||||
function spawnServer(env: Record<string, string>, port: number): Subprocess {
|
||||
const stateFile = path.join(tmpDir, 'browse-state.json');
|
||||
return spawn(['bun', 'run', SERVER_SCRIPT], {
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSE_STATE_FILE: stateFile,
|
||||
BROWSE_PORT: String(port),
|
||||
...env,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0); // signal 0 = existence check, no signal sent
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Read stdout until we see the expected marker or timeout. Returns the captured
|
||||
// text. Used to verify the watchdog code path ran as expected at startup.
|
||||
async function readStdoutUntil(
|
||||
proc: Subprocess,
|
||||
marker: string,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const decoder = new TextDecoder();
|
||||
let captured = '';
|
||||
const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
const readPromise = reader.read();
|
||||
const timed = Bun.sleep(Math.max(0, deadline - Date.now()));
|
||||
const result = await Promise.race([readPromise, timed.then(() => null)]);
|
||||
if (!result || result.done) break;
|
||||
captured += decoder.decode(result.value);
|
||||
if (captured.includes(marker)) return captured;
|
||||
}
|
||||
} finally {
|
||||
try { reader.releaseLock(); } catch {}
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe('parent-process watchdog (v0.18.1.0)', () => {
|
||||
test('BROWSE_PARENT_PID=0 disables the watchdog', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-pid0-'));
|
||||
serverProc = spawnServer({ BROWSE_PARENT_PID: '0' }, 34901);
|
||||
|
||||
const out = await readStdoutUntil(
|
||||
serverProc,
|
||||
'Parent-process watchdog disabled (BROWSE_PARENT_PID=0)',
|
||||
5000,
|
||||
);
|
||||
expect(out).toContain('Parent-process watchdog disabled (BROWSE_PARENT_PID=0)');
|
||||
// Control: the "parent exited, shutting down" line must NOT appear —
|
||||
// that would mean the watchdog ran after we said to skip it.
|
||||
expect(out).not.toContain('Parent process');
|
||||
}, 15_000);
|
||||
|
||||
test('BROWSE_HEADED=1 disables the watchdog (server-side guard)', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-headed-'));
|
||||
// Pass a bogus parent PID to prove BROWSE_HEADED takes precedence.
|
||||
// If the server-side guard regresses, the watchdog would try to poll
|
||||
// this PID and eventually fire on the "dead parent."
|
||||
serverProc = spawnServer(
|
||||
{ BROWSE_HEADED: '1', BROWSE_PARENT_PID: '999999' },
|
||||
34902,
|
||||
);
|
||||
|
||||
const out = await readStdoutUntil(
|
||||
serverProc,
|
||||
'Parent-process watchdog disabled (headed mode)',
|
||||
5000,
|
||||
);
|
||||
expect(out).toContain('Parent-process watchdog disabled (headed mode)');
|
||||
expect(out).not.toContain('Parent process 999999 exited');
|
||||
}, 15_000);
|
||||
|
||||
test('default headless mode: watchdog fires when parent dies', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-default-'));
|
||||
|
||||
// Spawn a real, short-lived "parent" that the watchdog will poll.
|
||||
parentProc = spawn(['sleep', '60'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
const parentPid = parentProc.pid!;
|
||||
|
||||
// Default headless: no BROWSE_HEADED, real parent PID — watchdog active.
|
||||
serverProc = spawnServer({ BROWSE_PARENT_PID: String(parentPid) }, 34903);
|
||||
const serverPid = serverProc.pid!;
|
||||
|
||||
// Give the server a moment to start and register the watchdog interval.
|
||||
await Bun.sleep(2000);
|
||||
expect(isProcessAlive(serverPid)).toBe(true);
|
||||
|
||||
// Kill the parent. The watchdog polls every 15s, so first tick after
|
||||
// parent death lands within ~15s, plus shutdown() cleanup time.
|
||||
parentProc.kill('SIGKILL');
|
||||
|
||||
// Poll for up to 25s for the server to exit.
|
||||
const deadline = Date.now() + 25_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessAlive(serverPid)) break;
|
||||
await Bun.sleep(500);
|
||||
}
|
||||
expect(isProcessAlive(serverPid)).toBe(false);
|
||||
}, 45_000);
|
||||
});
|
||||
Reference in New Issue
Block a user