From df6d2a161157011047b32ec7a22a5b071e573fa7 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 11 May 2026 23:25:37 -0700 Subject: [PATCH] feat(server): factory-export API surface + import.meta.main gate Surfaces the embedder API gbrowser (phoenix) needs to consume gstack as a submodule, and gates module-load side effects so the file is safe to import without auto-starting a daemon. Changes to browse/src/server.ts: - AUTH_TOKEN now honors process.env.AUTH_TOKEN (trimmed) before falling back to crypto.randomUUID(). Whitespace-only values are rejected so the security boundary can't be silently weakened. - New exported types: ServerConfig and ServerHandle. ServerConfig documents the full factory contract (authToken, browsePort, idleTimeoutMs, config, browserManager, chromiumProfile, xvfb, proxyBridge, startTime, beforeRoute). ServerHandle documents the return shape (fetchLocal, fetchTunnel, shutdown, stopListeners). Caller-owned lifecycle annotations on xvfb and proxyBridge prevent double-close bugs from surprise ownership. - New exported function: resolveConfigFromEnv() builds a ServerConfig-shaped object from process.env for CLI use. Embedders construct their own ServerConfig explicitly. - start() is now exported. Embedders can call it with env vars set as a v1 escape hatch until full buildFetchHandler extraction lands. - Signal handlers (SIGINT, SIGTERM, Windows exit, uncaughtException, unhandledRejection) and the auto-kickoff at module bottom are now wrapped in `if (import.meta.main)`. CLI path is unchanged. Embedders register their own handlers. - shutdown() and emergencyCleanup() now call cleanSingletonLocks( resolveChromiumProfile()) instead of inline path+loop. Single implementation, defensive guard, honors per-workspace CHROMIUM_PROFILE. New tests: - browse/test/server-no-import-side-effects.test.ts: spawns a fresh Bun subprocess that imports server.ts, asserts no signal handlers registered, no state-dir populated. Guards the core refactor invariant from regression. - browse/test/server-factory.test.ts: 12 tests covering AUTH_TOKEN env behavior (honored, whitespace-rejected, trimmed), preserved exports (TUNNEL_COMMANDS, canDispatchOverTunnel), and ServerConfig/ServerHandle type compatibility. Deferred to follow-up PR: full buildFetchHandler extraction that hoists the 13 module-level mutables + helpers into a factory closure. Phoenix can ship v0.6.0.0 against the start()+env surface today; the cleaner factory comes next. Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/src/server.ts | 237 +++++++++++++----- browse/test/server-factory.test.ts | 164 ++++++++++++ .../server-no-import-side-effects.test.ts | 106 ++++++++ 3 files changed, 442 insertions(+), 65 deletions(-) create mode 100644 browse/test/server-factory.test.ts create mode 100644 browse/test/server-no-import-side-effects.test.ts diff --git a/browse/src/server.ts b/browse/src/server.ts index 81af14acd..bf9c32e43 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -35,7 +35,7 @@ import { isRootToken, checkConnectRateLimit, type TokenInfo, } from './token-registry'; import { validateTempPath } from './path-security'; -import { resolveConfig, ensureStateDir, readVersionHash } from './config'; +import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks } from './config'; import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity'; import { initAuditLog, writeAuditEntry } from './audit'; import { inspectElement, modifyStyle, resetModifications, getModificationHistory, detachSession, type InspectorResult } from './cdp-inspector'; @@ -65,7 +65,11 @@ ensureStateDir(config); initAuditLog(config.auditLog); // ─── Auth ─────────────────────────────────────────────────────── -const AUTH_TOKEN = crypto.randomUUID(); +// AUTH_TOKEN is injectable via process.env.AUTH_TOKEN so embedders +// (gbrowser's gbd daemon spawn) can pre-allocate the token and hand it to +// the Bun child via env. Whitespace-only values fall back to randomUUID so +// the security boundary is never silently weakened by misconfiguration. +const AUTH_TOKEN = (process.env.AUTH_TOKEN?.trim()) || crypto.randomUUID(); initRegistry(AUTH_TOKEN); const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10); const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min @@ -97,6 +101,90 @@ let tunnelServer: ReturnType | null = null; // tunnel HTTP lis /** Which HTTP listener accepted this request. */ export type Surface = 'local' | 'tunnel'; +/** + * Factory contract for embedders (gbrowser phoenix overlay). + * + * Today the CLI calls `start()` which reads env vars and binds Bun.serve + * itself. Embedders building on this server as a submodule (gbrowser's + * fd-passing gbd architecture) need to inject auth + ports + a + * BrowserManager they pre-launched, and own the listener themselves. + * + * Status: v1 surfaces this type as documentation. AUTH_TOKEN env-injection + * is already live (see ~L70). `start()` is exported and the kickoff / + * signal-handler registration is gated on `import.meta.main`, so phoenix + * can `import { start } from '.../server'` without auto-starting. Full + * `buildFetchHandler` extraction lands in a follow-up; see plan + * `/Users/garrytan/.claude/plans/system-instruction-you-are-working-swirling-fountain.md` + * Part 1. + */ +export interface ServerConfig { + /** Bearer token clients must present. Today injected via AUTH_TOKEN env. */ + authToken: string; + /** Local listener port. Used in /welcome URL + state-file. */ + browsePort: number; + /** Idle shutdown timeout. Default 30 min. */ + idleTimeoutMs: number; + /** Result of resolveConfig() — stateDir, auditLog, stateFile. */ + config: ReturnType; + /** Pre-launched BrowserManager. Caller owns lifecycle. */ + browserManager: BrowserManager; + /** Optional Chromium profile path override. Resolved by resolveChromiumProfile(). */ + chromiumProfile?: string; + /** Caller-owned. shutdown() does NOT call xvfb.stop(); caller is responsible. */ + xvfb?: XvfbHandle | null; + /** Caller-owned. shutdown() does NOT call proxyBridge.close(); caller is responsible. */ + proxyBridge?: BridgeHandle | null; + startTime: number; + /** + * Overlay hook. Runs AFTER gstack resolves auth and BEFORE route dispatch. + * Invalid tokens are auto-rejected at the gstack layer (401 returned + * before hook fires), so the hook only ever sees valid TokenInfo or null + * (no token presented). Returning a Response short-circuits gstack + * dispatch; returning null falls through. + */ + beforeRoute?: (req: Request, surface: Surface, auth: TokenInfo | null) => Promise; +} + +/** + * Return shape of buildFetchHandler() — fetch handlers + lifecycle helpers + * embedders need to drive their own Bun.serve binding. See ServerConfig. + */ +export interface ServerHandle { + fetchLocal: (req: Request, server: any) => Promise; + fetchTunnel: (req: Request, server: any) => Promise; + /** + * Drains buffers, kills terminal-agent, closes browser, clears intervals, + * removes state files. Does NOT stop bound Bun.Server listeners — call + * stopListeners() for that. CLI relies on process.exit() to drop sockets. + */ + shutdown: (exitCode?: number) => Promise; + /** + * Graceful listener stop for embedders. Calls server.stop(true) on each + * passed Bun.Server. CLI doesn't need this (process.exit handles it). + */ + stopListeners: (local: any, tunnel?: any) => Promise; +} + +/** + * Build a ServerConfig-shaped object from process.env. Used by gstack's + * own CLI when running `bun run dev` or the compiled binary directly. + * Embedders construct their own ServerConfig explicitly. + * + * Reads env, calls resolveConfig(). Does NOT bind a listener or call + * initAuditLog/initRegistry — those happen inside the buildFetchHandler + * lifecycle. + */ +export function resolveConfigFromEnv(): Omit & { + config: ReturnType; +} { + return { + authToken: (process.env.AUTH_TOKEN?.trim()) || crypto.randomUUID(), + browsePort: parseInt(process.env.BROWSE_PORT || '0', 10), + idleTimeoutMs: parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10), + config: resolveConfig(), + }; +} + /** * Paths reachable over the tunnel surface. Everything else returns 404. * @@ -964,11 +1052,9 @@ async function shutdown(exitCode: number = 0) { await browserManager.close(); - // Clean up Chromium profile locks (prevent SingletonLock on next launch) - const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile'); - for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) { - safeUnlinkQuiet(path.join(profileDir, lockFile)); - } + // Clean up Chromium profile locks (prevent SingletonLock on next launch). + // Defensive guard inside the helper refuses to clean unrecognized dirs. + cleanSingletonLocks(resolveChromiumProfile()); // Clean up state file safeUnlinkQuiet(config.stateFile); @@ -983,36 +1069,41 @@ async function shutdown(exitCode: number = 0) { // passed as exitCode and process.exit() coerces it to NaN, exiting with code 1 // instead of 0. (Caught in v0.18.1.0 #1025.) // -// SIGINT (Ctrl+C): user intentionally stopping → shutdown. -process.on('SIGINT', () => shutdown()); -// SIGTERM behavior depends on mode: -// - Normal (headless) mode: Claude Code's Bash sandbox fires SIGTERM when the -// parent shell exits between tool invocations. Ignoring it keeps the server -// alive across $B calls. Idle timeout (30 min) handles eventual cleanup. -// - Headed / tunnel mode: idle timeout doesn't apply in these modes. Respect -// SIGTERM so external tooling (systemd, supervisord, CI) can shut cleanly -// without waiting forever. Ctrl+C and /stop still work either way. -// - Active cookie picker: never tear down mid-import regardless of mode — -// would strand the picker UI with "Failed to fetch." -process.on('SIGTERM', () => { - if (hasActivePicker()) { - console.log('[browse] Received SIGTERM but cookie picker is active, ignoring to avoid stranding the picker UI'); - return; - } - const headed = browserManager.getConnectionMode() === 'headed'; - if (headed || tunnelActive) { - console.log(`[browse] Received SIGTERM in ${headed ? 'headed' : 'tunnel'} mode, shutting down`); - shutdown(); - } else { - console.log('[browse] Received SIGTERM (ignoring — use /stop or Ctrl+C for intentional 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') { - process.on('exit', () => { - safeUnlinkQuiet(config.stateFile); +// Gated on `import.meta.main` so embedders (gbrowser phoenix) that import +// server.ts as a submodule can register their own signal handlers without +// fighting with gstack's. CLI path is unchanged. +if (import.meta.main) { + // SIGINT (Ctrl+C): user intentionally stopping → shutdown. + process.on('SIGINT', () => shutdown()); + // SIGTERM behavior depends on mode: + // - Normal (headless) mode: Claude Code's Bash sandbox fires SIGTERM when the + // parent shell exits between tool invocations. Ignoring it keeps the server + // alive across $B calls. Idle timeout (30 min) handles eventual cleanup. + // - Headed / tunnel mode: idle timeout doesn't apply in these modes. Respect + // SIGTERM so external tooling (systemd, supervisord, CI) can shut cleanly + // without waiting forever. Ctrl+C and /stop still work either way. + // - Active cookie picker: never tear down mid-import regardless of mode — + // would strand the picker UI with "Failed to fetch." + process.on('SIGTERM', () => { + if (hasActivePicker()) { + console.log('[browse] Received SIGTERM but cookie picker is active, ignoring to avoid stranding the picker UI'); + return; + } + const headed = browserManager.getConnectionMode() === 'headed'; + if (headed || tunnelActive) { + console.log(`[browse] Received SIGTERM in ${headed ? 'headed' : 'tunnel'} mode, shutting down`); + shutdown(); + } else { + console.log('[browse] Received SIGTERM (ignoring — use /stop or Ctrl+C for intentional 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') { + process.on('exit', () => { + safeUnlinkQuiet(config.stateFile); + }); + } } // Emergency cleanup for crashes (OOM, uncaught exceptions, browser disconnect) @@ -1044,26 +1135,37 @@ function emergencyCleanup() { } } catch { /* state file unparseable — fall through to lock + state cleanup */ } - // Clean Chromium profile locks - const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile'); - for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) { - safeUnlinkQuiet(path.join(profileDir, lockFile)); - } + // Clean Chromium profile locks via the shared helper (defensive guard + // refuses to operate on unrecognized profile dirs). + cleanSingletonLocks(resolveChromiumProfile()); safeUnlinkQuiet(config.stateFile); } -process.on('uncaughtException', (err) => { - console.error('[browse] FATAL uncaught exception:', err.message); - emergencyCleanup(); - process.exit(1); -}); -process.on('unhandledRejection', (err: any) => { - console.error('[browse] FATAL unhandled rejection:', err?.message || err); - emergencyCleanup(); - process.exit(1); -}); +// Same import.meta.main gate as SIGINT/SIGTERM — embedders register their +// own crash handlers. +if (import.meta.main) { + process.on('uncaughtException', (err) => { + console.error('[browse] FATAL uncaught exception:', err.message); + emergencyCleanup(); + process.exit(1); + }); + process.on('unhandledRejection', (err: any) => { + console.error('[browse] FATAL unhandled rejection:', err?.message || err); + emergencyCleanup(); + process.exit(1); + }); +} // ─── Start ───────────────────────────────────────────────────── -async function start() { +/** + * Entry point for `bun run dev` and the compiled binary. + * + * Exported so embedders (gbrowser phoenix overlay) can call it + * directly with env vars set, bypassing the module-level `import.meta.main` + * gate. Phoenix's eventual fd-passing path will use `buildFetchHandler` + * directly; until that lands, calling `start()` from a non-main entry is + * supported via env (AUTH_TOKEN, BROWSE_PORT, BROWSE_OWN_SIGNALS). + */ +export async function start() { // Clear old log files safeUnlink(CONSOLE_LOG_PATH); safeUnlink(NETWORK_LOG_PATH); @@ -2269,16 +2371,21 @@ async function start() { } } -start().catch((err) => { - console.error(`[browse] Failed to start: ${err.message}`); - // Write error to disk for the CLI to read — on Windows, the CLI can't capture - // stderr because the server is launched with detached: true, stdio: 'ignore'. - try { - const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log'); - mkdirSecure(config.stateDir); - writeSecureFile(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`); - } catch { - // stateDir may not exist — nothing more we can do - } - process.exit(1); -}); +// Auto-kickoff only when this module is the entry point. Embedders +// (gbrowser phoenix overlay) import { start, buildFetchHandler, ... } +// without triggering the listener-binding side effects. +if (import.meta.main) { + start().catch((err) => { + console.error(`[browse] Failed to start: ${err.message}`); + // Write error to disk for the CLI to read — on Windows, the CLI can't capture + // stderr because the server is launched with detached: true, stdio: 'ignore'. + try { + const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log'); + mkdirSecure(config.stateDir); + writeSecureFile(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`); + } catch { + // stateDir may not exist — nothing more we can do + } + process.exit(1); + }); +} diff --git a/browse/test/server-factory.test.ts b/browse/test/server-factory.test.ts new file mode 100644 index 000000000..3d7232baf --- /dev/null +++ b/browse/test/server-factory.test.ts @@ -0,0 +1,164 @@ +import { describe, test, expect } from 'bun:test'; +import { + resolveConfigFromEnv, + type ServerConfig, + type ServerHandle, + type Surface, +} from '../src/server'; +import { TUNNEL_COMMANDS, canDispatchOverTunnel } from '../src/server'; + +/** + * Tests for the factory-export API surface added so gbrowser (phoenix) can + * consume gstack as a submodule. The full buildFetchHandler hybrid hoist is + * deferred to a follow-up PR; this test file proves the type contract, + * resolveConfigFromEnv behavior, and preserved exports. + */ +describe('server.ts factory API surface', () => { + describe('resolveConfigFromEnv', () => { + test('honors AUTH_TOKEN env var', () => { + const orig = process.env.AUTH_TOKEN; + process.env.AUTH_TOKEN = 'fixed-test-token-abc123'; + try { + const cfg = resolveConfigFromEnv(); + expect(cfg.authToken).toBe('fixed-test-token-abc123'); + } finally { + if (orig === undefined) delete process.env.AUTH_TOKEN; + else process.env.AUTH_TOKEN = orig; + } + }); + + test('falls back to randomUUID when AUTH_TOKEN env is empty', () => { + const orig = process.env.AUTH_TOKEN; + process.env.AUTH_TOKEN = ''; + try { + const cfg = resolveConfigFromEnv(); + // randomUUID returns a 36-char hex+dash string. + expect(cfg.authToken).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + } finally { + if (orig === undefined) delete process.env.AUTH_TOKEN; + else process.env.AUTH_TOKEN = orig; + } + }); + + test('falls back to randomUUID when AUTH_TOKEN is whitespace-only', () => { + const orig = process.env.AUTH_TOKEN; + process.env.AUTH_TOKEN = ' \t \n '; + try { + const cfg = resolveConfigFromEnv(); + expect(cfg.authToken).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(cfg.authToken.length).toBe(36); + } finally { + if (orig === undefined) delete process.env.AUTH_TOKEN; + else process.env.AUTH_TOKEN = orig; + } + }); + + test('AUTH_TOKEN value is trimmed', () => { + const orig = process.env.AUTH_TOKEN; + process.env.AUTH_TOKEN = ' padded-token '; + try { + const cfg = resolveConfigFromEnv(); + expect(cfg.authToken).toBe('padded-token'); + } finally { + if (orig === undefined) delete process.env.AUTH_TOKEN; + else process.env.AUTH_TOKEN = orig; + } + }); + + test('reads BROWSE_PORT from env, defaults to 0', () => { + const orig = process.env.BROWSE_PORT; + process.env.BROWSE_PORT = '34567'; + try { + expect(resolveConfigFromEnv().browsePort).toBe(34567); + } finally { + if (orig === undefined) delete process.env.BROWSE_PORT; + else process.env.BROWSE_PORT = orig; + } + const origUnset = process.env.BROWSE_PORT; + delete process.env.BROWSE_PORT; + try { + expect(resolveConfigFromEnv().browsePort).toBe(0); + } finally { + if (origUnset !== undefined) process.env.BROWSE_PORT = origUnset; + } + }); + + test('reads BROWSE_IDLE_TIMEOUT from env, defaults to 30 min (1800000ms)', () => { + const orig = process.env.BROWSE_IDLE_TIMEOUT; + delete process.env.BROWSE_IDLE_TIMEOUT; + try { + expect(resolveConfigFromEnv().idleTimeoutMs).toBe(1800000); + } finally { + if (orig !== undefined) process.env.BROWSE_IDLE_TIMEOUT = orig; + } + }); + + test('returns a populated config object with the expected shape', () => { + const cfg = resolveConfigFromEnv(); + expect(cfg).toMatchObject({ + authToken: expect.any(String), + browsePort: expect.any(Number), + idleTimeoutMs: expect.any(Number), + config: expect.objectContaining({ + stateDir: expect.any(String), + stateFile: expect.any(String), + auditLog: expect.any(String), + }), + }); + }); + }); + + describe('preserved exports', () => { + test('TUNNEL_COMMANDS still exported and populated', () => { + expect(TUNNEL_COMMANDS).toBeInstanceOf(Set); + expect(TUNNEL_COMMANDS.size).toBeGreaterThan(0); + expect(TUNNEL_COMMANDS.has('goto')).toBe(true); + expect(TUNNEL_COMMANDS.has('click')).toBe(true); + }); + + test('canDispatchOverTunnel still exported and functional', () => { + expect(canDispatchOverTunnel('goto')).toBe(true); + expect(canDispatchOverTunnel('shutdown')).toBe(false); + expect(canDispatchOverTunnel(null)).toBe(false); + expect(canDispatchOverTunnel(undefined)).toBe(false); + expect(canDispatchOverTunnel('')).toBe(false); + }); + }); + + describe('type surface compiles', () => { + // Compile-time shape checks. If these break, TypeScript fails to build + // the test file — which is exactly the API-compat guarantee we want for + // embedders depending on these types. + test('Surface type accepts the two known values', () => { + const local: Surface = 'local'; + const tunnel: Surface = 'tunnel'; + expect(local).toBe('local'); + expect(tunnel).toBe('tunnel'); + }); + + test('ServerConfig type accepts the documented minimum-required fields', () => { + // This compiles only if ServerConfig accepts these field names + types. + const minimalConfigShape = { + authToken: 'tok', + browsePort: 0, + idleTimeoutMs: 1800000, + config: { stateDir: '', stateFile: '', consoleLog: '', networkLog: '', dialogLog: '', auditLog: '', projectDir: '' }, + browserManager: {} as any, + startTime: Date.now(), + } satisfies Partial; + expect(minimalConfigShape.authToken).toBe('tok'); + }); + + test('ServerHandle type exposes the documented surface', () => { + // Compiles only if these property names exist on ServerHandle. + type AssertHandleFields = ServerHandle extends { + fetchLocal: any; + fetchTunnel: any; + shutdown: any; + stopListeners: any; + } ? true : false; + const assertion: AssertHandleFields = true; + expect(assertion).toBe(true); + }); + }); +}); diff --git a/browse/test/server-no-import-side-effects.test.ts b/browse/test/server-no-import-side-effects.test.ts new file mode 100644 index 000000000..2dceec131 --- /dev/null +++ b/browse/test/server-no-import-side-effects.test.ts @@ -0,0 +1,106 @@ +import { describe, test, expect } from 'bun:test'; +import * as path from 'path'; +import * as os from 'os'; +import * as fs from 'fs'; + +/** + * Guard the core refactor invariant: importing browse/src/server.ts must NOT + * auto-start. Before this PR, the module called `start().catch(...)` at module + * load time, which made the file impossible for embedders (gbrowser phoenix + * overlay) to import without spawning a daemon. The fix wraps that kickoff in + * `if (import.meta.main)` so the side effects only run when the module is the + * process entry point. + * + * Approach: spawn a fresh Bun subprocess that imports the module and emits a + * structured snapshot (initial vs post-import process state). Parent asserts + * that no listeners were bound, no Bun.serve started, and no SIGINT handlers + * were registered. The subprocess uses HOME=tmp + GSTACK_HOME=tmp so any + * accidental state-dir write lands in a place we can verify is empty. + */ +describe('server.ts module import has no auto-start side effects', () => { + test('importing server.ts does not bind Bun.serve, register signal handlers, or write state', async () => { + const tmpHome = path.join(os.tmpdir(), `browse-no-sfx-${Date.now()}-${process.pid}`); + fs.mkdirSync(tmpHome, { recursive: true }); + const tmpGstack = path.join(tmpHome, '.gstack'); + + const childScript = ` +const sigintBefore = process.listenerCount('SIGINT'); +const sigtermBefore = process.listenerCount('SIGTERM'); +const uncaughtBefore = process.listenerCount('uncaughtException'); + +// Snapshot any keys that look like our state path. +const fs = require('fs'); +const path = require('path'); + +await import(${JSON.stringify(path.resolve(import.meta.dir, '../src/server.ts'))}); + +// After import, sleep a tick so any setTimeout(0)-style init can run. +await new Promise(r => setTimeout(r, 50)); + +const sigintAfter = process.listenerCount('SIGINT'); +const sigtermAfter = process.listenerCount('SIGTERM'); +const uncaughtAfter = process.listenerCount('uncaughtException'); + +// Check that the gstack home directory wasn't populated as a side effect. +let gstackPopulated = false; +try { + const entries = fs.readdirSync(${JSON.stringify(tmpGstack)}); + gstackPopulated = entries.length > 0; +} catch { + // Doesn't exist — that's the win we want. +} + +console.log(JSON.stringify({ + sigintBefore, sigintAfter, + sigtermBefore, sigtermAfter, + uncaughtBefore, uncaughtAfter, + gstackPopulated, +})); +// Force exit so any background intervals don't keep this child alive +// (the test framework would see a hang otherwise — which itself is a +// signal that side effects DID run). +process.exit(0); +`; + + const proc = Bun.spawn(['bun', '-e', childScript], { + env: { + ...process.env, + HOME: tmpHome, + GSTACK_HOME: tmpGstack, + // Empty so the AUTH_TOKEN env path doesn't deterministically set a token. + AUTH_TOKEN: '', + // Force a stub state file so resolveConfig() at module load (if it + // happens) won't crawl the host's real .gstack/. + BROWSE_STATE_FILE: path.join(tmpGstack, 'browse.json'), + }, + stdout: 'pipe', + stderr: 'pipe', + }); + + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + await proc.exited; + + // The last JSON line in stdout is our snapshot. + const jsonLine = stdout.trim().split('\n').filter(l => l.startsWith('{')).pop(); + expect(jsonLine, `child stderr: ${stderr}`).toBeDefined(); + + const snapshot = JSON.parse(jsonLine!); + + // No new signal handlers registered (gated on import.meta.main, which + // is false in the subprocess because `bun -e` is the entry point). + expect(snapshot.sigintAfter).toBe(snapshot.sigintBefore); + expect(snapshot.sigtermAfter).toBe(snapshot.sigtermBefore); + expect(snapshot.uncaughtAfter).toBe(snapshot.uncaughtBefore); + + // gstack home should remain empty — initRegistry/initAuditLog/etc. side + // effects from module load are acceptable (they happen at module level), + // but only insofar as they don't bind listeners or write project state. + // The presence/absence test here proves we didn't bind Bun.serve (which + // would also try to write the state file). + expect(snapshot.gstackPopulated).toBe(false); + + // Cleanup + try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* best effort */ } + }, 30_000); +});