Merge remote-tracking branch 'origin/main' into garrytan/sidebar-claude-timeouts

This commit is contained in:
Garry Tan
2026-05-24 00:07:17 -07:00
65 changed files with 3231 additions and 181 deletions
+17 -6
View File
@@ -59,6 +59,13 @@ export function isCustomChromium(): boolean {
*/
export function shouldEnableChromiumSandbox(): boolean {
if (process.platform === 'win32') return false;
// Explicit user override for Ubuntu/AppArmor and similar environments where
// unprivileged Chromium sandboxing is blocked even for normal users (the
// sandbox needs unprivileged user namespaces that the host policy denies,
// so /qa hangs without --no-sandbox). Setting GSTACK_CHROMIUM_NO_SANDBOX=1
// forces the sandbox off without changing the default for everyone else.
// See #1562.
if (process.env.GSTACK_CHROMIUM_NO_SANDBOX === '1') return false;
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
return !(process.env.CI || process.env.CONTAINER || isRoot);
}
@@ -300,12 +307,16 @@ export class BrowserManager {
}
if (extensionsDir) {
launchArgs.push(
`--disable-extensions-except=${extensionsDir}`,
`--load-extension=${extensionsDir}`,
'--window-position=-9999,-9999',
'--window-size=1,1',
);
// Skip --load-extension when running against a custom Chromium build that
// already bakes the extension in (e.g., GBrowser / GStack Browser.app).
// Loading it twice causes a ServiceWorkerState::SetWorkerId DCHECK crash.
if (!isCustomChromium()) {
launchArgs.push(
`--disable-extensions-except=${extensionsDir}`,
`--load-extension=${extensionsDir}`,
);
}
launchArgs.push('--window-position=-9999,-9999', '--window-size=1,1');
useHeadless = false; // extensions require headed mode; off-screen window simulates headless
console.log(`[browse] Extensions loaded from: ${extensionsDir}`);
}
+26 -27
View File
@@ -11,6 +11,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { spawn as nodeSpawn } from 'child_process';
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
@@ -218,8 +219,6 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
safeUnlink(config.stateFile);
safeUnlink(path.join(config.stateDir, 'browse-startup-error.log'));
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
@@ -241,12 +240,22 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
`${extraEnvStr})}).unref()`;
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
} else {
// macOS/Linux: Bun.spawn + unref works correctly
proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
stdio: ['ignore', 'pipe', 'pipe'],
// 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
// parent's process session. When the CLI runs inside a session-managed
// shell (e.g. Claude Code's per-command Bash sandbox, Conductor, CI
// step runners), the session leader's exit sends SIGHUP to every PID in
// the session, killing the bun server (and its Chromium grandchildren).
// Even with BROWSE_PARENT_PID=0 disabling the watchdog, SIGHUP still
// reaps the server. Use Node's child_process.spawn with detached:true,
// which calls setsid() so the server becomes its own session leader
// (PPID=1, STAT=Ss) and survives the spawning shell's exit. Mirrors
// the Windows path's rationale — same root cause, different OS API.
nodeSpawn('bun', ['run', SERVER_SCRIPT], {
detached: true,
stdio: ['ignore', 'ignore', 'ignore'],
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...extraEnv },
});
proc.unref();
}).unref();
}
// Wait for server to become healthy.
@@ -261,27 +270,17 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
await Bun.sleep(100);
}
// Server didn't start in time — try to get error details
if (proc?.stderr) {
// macOS/Linux: read stderr from the spawned process
const reader = proc.stderr.getReader();
const { value } = await reader.read();
if (value) {
const errText = new TextDecoder().decode(value);
throw new Error(`Server failed to start:\n${errText}`);
}
} else {
// Windows: check startup error log (server writes errors to disk since
// stderr is unavailable due to stdio: 'ignore' for detachment)
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
try {
const errorLog = fs.readFileSync(errorLogPath, 'utf-8').trim();
if (errorLog) {
throw new Error(`Server failed to start:\n${errorLog}`);
}
} catch (e: any) {
if (e.code !== 'ENOENT') throw e;
// Server didn't start in time — check the on-disk startup error log.
// Both platforms now spawn with stdio: 'ignore', so the server writes
// errors to disk for the CLI to read (see server.ts start().catch).
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
try {
const errorLog = fs.readFileSync(errorLogPath, 'utf-8').trim();
if (errorLog) {
throw new Error(`Server failed to start:\n${errorLog}`);
}
} catch (e: any) {
if (e.code !== 'ENOENT') throw e;
}
throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
}
+62 -6
View File
@@ -623,17 +623,39 @@ function resetIdleTimer() {
lastActivity = Date.now();
}
const idleCheckInterval = setInterval(() => {
// Named for behavioral testing via __testInternals__. The factory tests in
// server-factory.test.ts call this directly so the idle-shutdown path can be
// exercised without waiting 60s for the interval to fire.
function idleCheckTick() {
// Headed mode: the user is looking at the browser. Never auto-die.
// Only shut down when the user explicitly disconnects or closes the window.
if (browserManager.getConnectionMode() === 'headed') return;
// Reads via the activeBrowserManager indirection so embedders that pass
// their own BrowserManager into buildFetchHandler hit the right instance.
if (activeBrowserManager.getConnectionMode() === 'headed') return;
// Tunnel mode: remote agents may send commands sporadically. Never auto-die.
if (tunnelActive) return;
if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) {
console.log(`[browse] Idle for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`);
activeShutdown?.();
}
}, 60_000);
}
const idleCheckInterval = setInterval(idleCheckTick, 60_000);
// Test-only surface for server-factory.test.ts. Lets the dual-instance
// idle-timer behavior be exercised deterministically without mutating
// Date.now (which would interact with the leaked module-level setInterval).
// Production code must never import this — see `idle timer + onDisconnect
// dual-instance fix` describe block for usage.
export const __testInternals__ = {
idleCheckTick,
setTunnelActive: (v: boolean) => { tunnelActive = v; },
setLastActivity: (t: number) => { lastActivity = t; },
// Reset the module-level shutdown latch so tests that drive shutdown to
// completion (process.exit-stubbed) can be followed by tests that also
// need shutdown to fire. Without this, the second test's shutdown
// returns early at the `if (isShuttingDown) return;` guard.
resetShutdownState: () => { isShuttingDown = false; },
};
// ─── Parent-Process Watchdog ────────────────────────────────────────
// When the spawning CLI process (e.g. a Claude Code session) exits, this
@@ -671,7 +693,7 @@ if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
// the parent shell between invocations. The idle timeout (30 min)
// handles eventual cleanup.
if (hasActivePicker()) return;
const headed = browserManager.getConnectionMode() === 'headed';
const headed = activeBrowserManager.getConnectionMode() === 'headed';
if (headed || tunnelActive) {
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
activeShutdown?.();
@@ -711,13 +733,22 @@ function emitInspectorEvent(event: any): void {
// ─── Server ────────────────────────────────────────────────────
const browserManager = new BrowserManager();
// Indirection for embedders. Module-level handlers (idleCheckTick, parent
// watchdog, SIGTERM) read activeBrowserManager so that buildFetchHandler can
// retarget them at a caller-supplied BrowserManager. Symmetric with the
// existing `let activeShutdown` pattern at module scope (line ~113).
// Without this, embedders like gbrowser hit the dead module-level instance
// whose connectionMode never leaves 'launched' — and headed mode never
// short-circuits idle-shutdown.
let activeBrowserManager: BrowserManager = 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. Exit code 0 means user-initiated clean quit (Cmd+Q on
// macOS) so process supervisors like gbrowser's gbd skip the restart loop;
// 2 means a real crash that should respawn. The fallback `?? 2` preserves
// legacy crash semantics for any caller that invokes onDisconnect without
// an explicit code.
// an explicit code. This is the safety-net default for the CLI flow before
// any buildFetchHandler call rebinds onDisconnect onto the cfg instance.
browserManager.onDisconnect = (code) => activeShutdown?.(code ?? 2);
let isShuttingDown = false;
@@ -1216,7 +1247,7 @@ if (import.meta.main) {
console.log('[browse] Received SIGTERM but cookie picker is active, ignoring to avoid stranding the picker UI');
return;
}
const headed = browserManager.getConnectionMode() === 'headed';
const headed = activeBrowserManager.getConnectionMode() === 'headed';
if (headed || tunnelActive) {
console.log(`[browse] Received SIGTERM in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
activeShutdown?.();
@@ -1478,6 +1509,31 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
// differs from the module-level instance.
activeShutdown = shutdown;
// Retarget the BrowserManager indirection at the cfg-instance so the
// module-level idleCheckTick + parent watchdog + SIGTERM handler all read
// the right connectionMode. Without this, headed embedders auto-shutdown
// after 30 min of HTTP idle because the dead module-level instance still
// reports connectionMode === 'launched'.
activeBrowserManager = cfgBrowserManager;
// Wire the cfg-instance's onDisconnect to run shutdown when the user
// closes the headed browser window. CHAIN any caller-provided handler
// instead of overwriting it: gbrowser may have set its own onDisconnect
// before calling buildFetchHandler (e.g. for snapshot/log work that needs
// to run before the process exits). Caller errors are logged but never
// block gstack shutdown — defensive symmetry with the safeUnlinkQuiet /
// safeKill philosophy in error-handling.ts.
const callerOnDisconnect = cfgBrowserManager.onDisconnect;
cfgBrowserManager.onDisconnect = async (code) => {
if (callerOnDisconnect) {
try { await callerOnDisconnect(code); }
catch (err: any) {
console.warn('[browse] caller onDisconnect threw:', err?.message ?? err);
}
}
await activeShutdown?.(code ?? 2);
};
// Substitute cfgBrowserManager for module-level browserManager in the
// dispatcher body so all browser-state reads/writes go through the cfg
// instance. Other module-level references (handleCommand, getTokenInfo,