mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-06 08:07:53 +02:00
Merge remote-tracking branch 'origin/main' into garrytan/sidebar-claude-timeouts
This commit is contained in:
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user