diff --git a/browse/src/config.ts b/browse/src/config.ts index d7c8c9ef5..e8770ef45 100644 --- a/browse/src/config.ts +++ b/browse/src/config.ts @@ -11,8 +11,10 @@ */ import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import { mkdirSecure } from './file-permissions'; +import { safeUnlinkQuiet } from './error-handling'; export interface BrowseConfig { projectDir: string; @@ -151,3 +153,57 @@ export function readVersionHash(execPath: string = process.execPath): string | n return null; } } + +/** + * Resolve the gstack home directory. + * + * Honors the existing convention used by telemetry.ts and domain-skills.ts: + * 1. GSTACK_HOME env (explicit override) + * 2. $HOME/.gstack (default) + */ +export function resolveGstackHome(): string { + return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); +} + +/** + * Resolve the Chromium profile directory. + * + * Resolution order: + * 1. `explicit` arg (passed via ServerConfig.chromiumProfile by embedders) + * 2. CHROMIUM_PROFILE env (used by gbrowser's gbd per-workspace) + * 3. /chromium-profile (default) + */ +export function resolveChromiumProfile(explicit?: string): string { + if (explicit && explicit.length > 0) return explicit; + const env = process.env.CHROMIUM_PROFILE; + if (env && env.length > 0) return env; + return path.join(resolveGstackHome(), 'chromium-profile'); +} + +/** + * Pre-launch / shutdown cleanup of stale Chromium singleton lockfiles + * (SingletonLock, SingletonSocket, SingletonCookie). Chromium's + * ProcessSingleton refuses to start when these exist from a prior crash + * (SIGKILL, hard crash, etc.) since they point at a PID that no longer exists. + * + * Defensive guard: refuses to operate unless the directory basename is + * 'chromium-profile' OR the path matches the explicit CHROMIUM_PROFILE env + * value. Prevents accidentally deleting lock files from an unrelated + * directory if profile resolution is misconfigured upstream. + * + * Caller MUST ensure external coordination has already guaranteed no live + * peer is using this profile (gbd.lock for gbrowser; single-instance CLI + * check for gstack). + */ +export function cleanSingletonLocks(userDataDir: string): void { + const basename = path.basename(userDataDir); + const explicitProfile = process.env.CHROMIUM_PROFILE; + const isSafe = basename === 'chromium-profile' || userDataDir === explicitProfile; + if (!isSafe) { + console.warn(`[browse] cleanSingletonLocks: refusing to clean unrecognized profile dir: ${userDataDir}`); + return; + } + for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) { + safeUnlinkQuiet(path.join(userDataDir, lockFile)); + } +} diff --git a/browse/test/config.test.ts b/browse/test/config.test.ts index b36426947..8daa27c3e 100644 --- a/browse/test/config.test.ts +++ b/browse/test/config.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test'; -import { resolveConfig, ensureStateDir, readVersionHash, getGitRoot, getRemoteSlug } from '../src/config'; +import { resolveConfig, ensureStateDir, readVersionHash, getGitRoot, getRemoteSlug, resolveGstackHome, resolveChromiumProfile, cleanSingletonLocks } from '../src/config'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -314,3 +314,132 @@ describe('startup error log', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); }); + +describe('resolveGstackHome', () => { + test('honors GSTACK_HOME env var when set', () => { + const orig = process.env.GSTACK_HOME; + process.env.GSTACK_HOME = '/tmp/custom-gstack-home'; + try { + expect(resolveGstackHome()).toBe('/tmp/custom-gstack-home'); + } finally { + if (orig === undefined) delete process.env.GSTACK_HOME; + else process.env.GSTACK_HOME = orig; + } + }); + + test('falls back to os.homedir() + /.gstack when env unset', () => { + const orig = process.env.GSTACK_HOME; + delete process.env.GSTACK_HOME; + try { + expect(resolveGstackHome()).toBe(path.join(os.homedir(), '.gstack')); + } finally { + if (orig !== undefined) process.env.GSTACK_HOME = orig; + } + }); +}); + +describe('resolveChromiumProfile', () => { + test('explicit arg wins over env and default', () => { + const orig = process.env.CHROMIUM_PROFILE; + process.env.CHROMIUM_PROFILE = '/tmp/env-profile'; + try { + expect(resolveChromiumProfile('/tmp/explicit-profile')).toBe('/tmp/explicit-profile'); + } finally { + if (orig === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = orig; + } + }); + + test('CHROMIUM_PROFILE env honored when no explicit arg', () => { + const orig = process.env.CHROMIUM_PROFILE; + process.env.CHROMIUM_PROFILE = '/tmp/env-profile'; + try { + expect(resolveChromiumProfile()).toBe('/tmp/env-profile'); + } finally { + if (orig === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = orig; + } + }); + + test('falls back to resolveGstackHome()/chromium-profile when nothing set', () => { + const origEnv = process.env.CHROMIUM_PROFILE; + const origHome = process.env.GSTACK_HOME; + delete process.env.CHROMIUM_PROFILE; + process.env.GSTACK_HOME = '/tmp/fallback-gstack'; + try { + expect(resolveChromiumProfile()).toBe('/tmp/fallback-gstack/chromium-profile'); + } finally { + if (origEnv !== undefined) process.env.CHROMIUM_PROFILE = origEnv; + if (origHome === undefined) delete process.env.GSTACK_HOME; + else process.env.GSTACK_HOME = origHome; + } + }); + + test('ignores empty-string explicit arg, falls through to env/default', () => { + const orig = process.env.CHROMIUM_PROFILE; + process.env.CHROMIUM_PROFILE = '/tmp/env-profile'; + try { + expect(resolveChromiumProfile('')).toBe('/tmp/env-profile'); + } finally { + if (orig === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = orig; + } + }); +}); + +describe('cleanSingletonLocks', () => { + test('removes SingletonLock/Socket/Cookie when basename is chromium-profile', () => { + const tmpDir = path.join(os.tmpdir(), `clean-locks-${Date.now()}`, 'chromium-profile'); + fs.mkdirSync(tmpDir, { recursive: true }); + for (const f of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) { + fs.writeFileSync(path.join(tmpDir, f), 'stale'); + } + cleanSingletonLocks(tmpDir); + for (const f of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) { + expect(fs.existsSync(path.join(tmpDir, f))).toBe(false); + } + fs.rmSync(path.dirname(tmpDir), { recursive: true, force: true }); + }); + + test('refuses to clean unrecognized profile dir basename', () => { + const tmpDir = path.join(os.tmpdir(), `unrelated-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + const lockFile = path.join(tmpDir, 'SingletonLock'); + fs.writeFileSync(lockFile, 'should-survive'); + const origWarn = console.warn; + let warned = ''; + console.warn = (msg: string) => { warned = msg; }; + try { + cleanSingletonLocks(tmpDir); + expect(warned).toContain('refusing to clean unrecognized profile dir'); + expect(fs.existsSync(lockFile)).toBe(true); // not deleted + } finally { + console.warn = origWarn; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + test('respects explicit CHROMIUM_PROFILE env even with non-standard basename', () => { + const tmpDir = path.join(os.tmpdir(), `custom-name-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'SingletonLock'), 'stale'); + const orig = process.env.CHROMIUM_PROFILE; + process.env.CHROMIUM_PROFILE = tmpDir; + try { + cleanSingletonLocks(tmpDir); + expect(fs.existsSync(path.join(tmpDir, 'SingletonLock'))).toBe(false); + } finally { + if (orig === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = orig; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + test('second call on empty dir does not throw (ENOENT swallowed)', () => { + const tmpDir = path.join(os.tmpdir(), `empty-locks-${Date.now()}`, 'chromium-profile'); + fs.mkdirSync(tmpDir, { recursive: true }); + expect(() => cleanSingletonLocks(tmpDir)).not.toThrow(); + expect(() => cleanSingletonLocks(tmpDir)).not.toThrow(); + fs.rmSync(path.dirname(tmpDir), { recursive: true, force: true }); + }); +});