singularity-forge/src/web-mode.ts
Tom Boucher de9ba8aeb7 fix: add windowsHide to all web-mode subprocess spawns (#2628) (#3046)
On Windows, child_process.spawn() and execFile() open a visible console
window by default. The web server spawn, RPC bridge, browser opener, and
all 15 web service subprocess calls were missing windowsHide: true,
causing constant console window flashing when running gsd --web.

Closes #2628

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 14:50:13 -06:00

731 lines
24 KiB
TypeScript

import { randomBytes } from 'node:crypto'
import { exec, execFile, spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { request as httpRequest } from 'node:http'
import { createServer } from 'node:net'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { appRoot, webPidFilePath as defaultWebPidFilePath } from './app-paths.js'
const DEFAULT_HOST = '127.0.0.1'
const DEFAULT_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
/** Open a URL in the user's default browser. */
function openBrowser(url: string): void {
if (process.platform === 'win32') {
// PowerShell's Start-Process handles URLs with '&' safely; cmd /c start does not.
execFile('powershell', ['-c', `Start-Process '${url.replace(/'/g, "''")}'`], { windowsHide: true }, () => {})
} else {
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open'
execFile(cmd, [url], () => {})
}
}
type WritableLike = Pick<typeof process.stderr, 'write'>
type ResourceBootstrapLike = {
initResources: (agentDir: string) => void
}
type SpawnedChildLike = Pick<ChildProcess, 'once' | 'unref' | 'pid'>
export interface WebModeLaunchOptions {
cwd: string
projectSessionsDir: string
agentDir: string
packageRoot?: string
host?: string
port?: number
/** Additional allowed origins for CORS (forwarded as GSD_WEB_ALLOWED_ORIGINS). */
allowedOrigins?: string[]
}
export interface ResolvedWebHostBootstrap {
ok: true
kind: 'packaged-standalone' | 'source-dev'
packageRoot: string
hostRoot: string
entryPath: string
}
export interface UnresolvedWebHostBootstrap {
ok: false
packageRoot: string
reason: string
candidates: string[]
}
export type WebHostBootstrap = ResolvedWebHostBootstrap | UnresolvedWebHostBootstrap
export interface WebModeLaunchSuccess {
mode: 'web'
ok: true
cwd: string
projectSessionsDir: string
host: string
port: number
url: string
hostKind: ResolvedWebHostBootstrap['kind']
hostPath: string
hostRoot: string
}
export interface WebModeLaunchFailure {
mode: 'web'
ok: false
cwd: string
projectSessionsDir: string
host: string
port: number | null
url: string | null
hostKind: ResolvedWebHostBootstrap['kind'] | 'unresolved'
hostPath: string | null
hostRoot: string | null
failureReason: string
candidates?: string[]
}
export type WebModeLaunchStatus = WebModeLaunchSuccess | WebModeLaunchFailure
export interface WebModeDeps {
existsSync?: (path: string) => boolean
initResources?: (agentDir: string) => void
resolvePort?: (host: string) => Promise<number>
spawn?: (command: string, args: readonly string[], options: SpawnOptions) => SpawnedChildLike
waitForBootReady?: (url: string) => Promise<void>
openBrowser?: (url: string) => void
stderr?: WritableLike
env?: NodeJS.ProcessEnv
platform?: NodeJS.Platform
execPath?: string
pidFilePath?: string
writePidFile?: (path: string, pid: number) => void
readPidFile?: (path: string) => number | null
deletePidFile?: (path: string) => void
/** Path to the multi-instance registry JSON (for testing). */
registryPath?: string
}
export interface WebModeStopResult {
ok: boolean
reason?: string
/** How many instances were stopped (relevant for --all) */
stoppedCount?: number
}
// ─── Instance Registry ──────────────────────────────────────────────────────
export interface WebInstanceEntry {
pid: number
port: number
url: string
cwd: string
startedAt: string
}
export type WebInstanceRegistry = Record<string, WebInstanceEntry>
const WEB_INSTANCES_PATH = join(appRoot, 'web-instances.json')
export function readInstanceRegistry(registryPath = WEB_INSTANCES_PATH): WebInstanceRegistry {
try {
return JSON.parse(readFileSync(registryPath, 'utf8')) as WebInstanceRegistry
} catch {
return {}
}
}
export function writeInstanceRegistry(registry: WebInstanceRegistry, registryPath = WEB_INSTANCES_PATH): void {
writeFileSync(registryPath, JSON.stringify(registry, null, 2), 'utf8')
}
export function registerInstance(cwd: string, entry: Omit<WebInstanceEntry, 'cwd' | 'startedAt'>, registryPath = WEB_INSTANCES_PATH): void {
const registry = readInstanceRegistry(registryPath)
registry[resolve(cwd)] = {
...entry,
cwd: resolve(cwd),
startedAt: new Date().toISOString(),
}
writeInstanceRegistry(registry, registryPath)
}
export function unregisterInstance(cwd: string, registryPath = WEB_INSTANCES_PATH): void {
const registry = readInstanceRegistry(registryPath)
delete registry[resolve(cwd)]
writeInstanceRegistry(registry, registryPath)
}
function killPid(pid: number): 'killed' | 'already-dead' | { error: string } {
try {
process.kill(pid, 'SIGTERM')
return 'killed'
} catch (error) {
const isAlreadyDead = error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ESRCH'
if (isAlreadyDead) return 'already-dead'
return { error: error instanceof Error ? error.message : String(error) }
}
}
export function writePidFile(filePath: string, pid: number): void {
writeFileSync(filePath, String(pid), 'utf8')
}
export function readPidFile(filePath: string): number | null {
try {
const content = readFileSync(filePath, 'utf8').trim()
const pid = parseInt(content, 10)
return Number.isFinite(pid) && pid > 0 ? pid : null
} catch {
return null
}
}
export function deletePidFile(filePath: string): void {
try {
unlinkSync(filePath)
} catch {
// Non-fatal — file may already be gone
}
}
export interface WebModeStopOptions {
/** Stop instance for a specific project path */
projectCwd?: string
/** Stop all running instances */
all?: boolean
}
export function stopWebMode(deps: Pick<WebModeDeps, 'pidFilePath' | 'readPidFile' | 'deletePidFile' | 'stderr'> = {}, options: WebModeStopOptions = {}): WebModeStopResult {
const stderr = deps.stderr ?? process.stderr
// ── Stop all instances ──────────────────────────────────────────────
if (options.all) {
const registry = readInstanceRegistry()
const entries = Object.entries(registry)
if (entries.length === 0) {
// Fall back to legacy PID file
return stopLegacyPidFile(deps)
}
let stopped = 0
for (const [cwd, entry] of entries) {
const result = killPid(entry.pid)
if (result === 'killed') {
stderr.write(`[gsd] Stopped web server for ${cwd} (pid=${entry.pid})\n`)
stopped++
} else if (result === 'already-dead') {
stderr.write(`[gsd] Web server for ${cwd} was already stopped (pid=${entry.pid})\n`)
stopped++
} else {
stderr.write(`[gsd] Failed to stop web server for ${cwd}: ${result.error}\n`)
}
unregisterInstance(cwd)
}
// Also clean up legacy PID file
const deletePid = deps.deletePidFile ?? deletePidFile
const pidFilePath = deps.pidFilePath ?? defaultWebPidFilePath
deletePid(pidFilePath)
stderr.write(`[gsd] Stopped ${stopped} instance${stopped === 1 ? '' : 's'}.\n`)
return { ok: true, stoppedCount: stopped }
}
// ── Stop specific project ──────────────────────────────────────────
if (options.projectCwd) {
const resolvedCwd = resolve(options.projectCwd)
const registry = readInstanceRegistry()
const entry = registry[resolvedCwd]
if (!entry) {
stderr.write(`[gsd] No web server running for ${resolvedCwd}\n`)
return { ok: false, reason: 'not-found' }
}
const result = killPid(entry.pid)
unregisterInstance(resolvedCwd)
if (result === 'killed') {
stderr.write(`[gsd] Stopped web server for ${resolvedCwd} (pid=${entry.pid})\n`)
return { ok: true, stoppedCount: 1 }
} else if (result === 'already-dead') {
stderr.write(`[gsd] Web server for ${resolvedCwd} was already stopped — cleared stale entry.\n`)
return { ok: true, stoppedCount: 1 }
} else {
stderr.write(`[gsd] Failed to stop web server for ${resolvedCwd}: ${result.error}\n`)
return { ok: false, reason: result.error }
}
}
// ── Default: stop via legacy PID file (backward compat) ─────────────
return stopLegacyPidFile(deps)
}
function stopLegacyPidFile(deps: Pick<WebModeDeps, 'pidFilePath' | 'readPidFile' | 'deletePidFile' | 'stderr'>): WebModeStopResult {
const stderr = deps.stderr ?? process.stderr
const pidFilePath = deps.pidFilePath ?? defaultWebPidFilePath
const readPid = deps.readPidFile ?? readPidFile
const deletePid = deps.deletePidFile ?? deletePidFile
const pid = readPid(pidFilePath)
if (pid === null) {
stderr.write(`[gsd] Web server is not running (no PID file found)\n`)
return { ok: false, reason: 'no-pid-file' }
}
stderr.write(`[gsd] Stopping web server (pid=${pid})…\n`)
const result = killPid(pid)
deletePid(pidFilePath)
if (result === 'killed') {
stderr.write(`[gsd] Web server stopped.\n`)
return { ok: true }
} else if (result === 'already-dead') {
stderr.write(`[gsd] Web server was already stopped — cleared stale PID file.\n`)
return { ok: true }
} else {
stderr.write(`[gsd] Failed to stop web server: ${result.error}\n`)
return { ok: false, reason: result.error }
}
}
async function loadResourceBootstrap(): Promise<ResourceBootstrapLike> {
const mod = await import('./resource-loader.js')
return {
initResources: mod.initResources,
}
}
export function resolveWebHostBootstrap(options: {
packageRoot?: string
existsSync?: (path: string) => boolean
} = {}): WebHostBootstrap {
const packageRoot = options.packageRoot ?? DEFAULT_PACKAGE_ROOT
const checkExists = options.existsSync ?? existsSync
const packagedStandaloneServer = join(packageRoot, 'dist', 'web', 'standalone', 'server.js')
if (checkExists(packagedStandaloneServer)) {
return {
ok: true,
kind: 'packaged-standalone',
packageRoot,
hostRoot: join(packageRoot, 'dist', 'web', 'standalone'),
entryPath: packagedStandaloneServer,
}
}
const sourceWebRoot = join(packageRoot, 'web')
const sourceManifest = join(sourceWebRoot, 'package.json')
if (checkExists(sourceManifest)) {
return {
ok: true,
kind: 'source-dev',
packageRoot,
hostRoot: sourceWebRoot,
entryPath: sourceManifest,
}
}
return {
ok: false,
packageRoot,
reason: 'host bootstrap not found',
candidates: [packagedStandaloneServer, sourceManifest],
}
}
export async function reserveWebPort(host = DEFAULT_HOST): Promise<number> {
return await new Promise<number>((resolvePort, reject) => {
const server = createServer()
server.unref()
server.once('error', reject)
server.listen(0, host, () => {
const address = server.address()
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('failed to determine reserved web port')))
return
}
server.close((error) => {
if (error) {
reject(error)
return
}
resolvePort(address.port)
})
})
})
}
function getSpawnCommandForSourceHost(platform: NodeJS.Platform): string {
return platform === 'win32' ? 'npm.cmd' : 'npm'
}
function formatLaunchStatus(status: WebModeLaunchStatus): string {
if (status.ok) {
return `[gsd] Web mode startup: status=started cwd=${status.cwd} port=${status.port} host=${status.hostPath} kind=${status.hostKind} url=${status.url}\n`
}
return `[gsd] Web mode startup: status=failed cwd=${status.cwd} port=${status.port ?? 'n/a'} host=${status.hostPath ?? 'unresolved'} kind=${status.hostKind} reason=${status.failureReason}\n`
}
function emitLaunchStatus(stderr: WritableLike, status: WebModeLaunchStatus): void {
stderr.write(formatLaunchStatus(status))
}
function buildSpawnSpec(
resolution: ResolvedWebHostBootstrap,
host: string,
port: number,
platform: NodeJS.Platform,
execPath: string,
): { command: string; args: string[]; cwd: string } {
if (resolution.kind === 'packaged-standalone') {
return {
command: execPath,
args: [resolution.entryPath],
cwd: resolution.hostRoot,
}
}
return {
command: getSpawnCommandForSourceHost(platform),
args: ['run', 'dev', '--', '--hostname', host, '--port', String(port)],
cwd: resolution.hostRoot,
}
}
async function spawnDetachedProcess(
spawnCommand: (command: string, args: readonly string[], options: SpawnOptions) => SpawnedChildLike,
command: string,
args: string[],
options: SpawnOptions,
): Promise<{ ok: true; child: SpawnedChildLike } | { ok: false; error: unknown }> {
return await new Promise((resolve) => {
try {
const child = spawnCommand(command, args, options)
let settled = false
const finish = (result: { ok: true; child: SpawnedChildLike } | { ok: false; error: unknown }) => {
if (settled) return
settled = true
resolve(result)
}
child.once?.('error', (error) => finish({ ok: false, error }))
setImmediate(() => finish({ ok: true, child }))
} catch (error) {
resolve({ ok: false, error })
}
})
}
async function requestLocalJson(url: string, timeoutMs: number, authToken?: string): Promise<{ statusCode: number; body: string }> {
return await new Promise((resolve, reject) => {
const headers: Record<string, string> = {
Accept: 'application/json',
// Keep launch readiness on the cheapest uncompressed path. The
// packaged host can spend noticeable time compressing the large boot
// snapshot, which adds avoidable startup jitter for a local health
// check that only needs the JSON payload itself.
'Accept-Encoding': 'identity',
}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`
}
const request = httpRequest(
url,
{
method: 'GET',
headers,
},
(response) => {
const statusCode = response.statusCode ?? 0
let body = ''
response.setEncoding('utf8')
response.on('data', (chunk) => {
body += chunk
})
response.on('end', () => resolve({ statusCode, body }))
},
)
request.setTimeout(timeoutMs, () => {
request.destroy(new Error(`request timed out after ${timeoutMs}ms`))
})
request.once('error', reject)
request.end()
})
}
async function waitForBootReady(url: string, timeoutMs = 180_000, stderr?: WritableLike, authToken?: string): Promise<void> {
const deadline = Date.now() + timeoutMs
const startedAt = Date.now()
let lastError: string | null = null
let lastBody: string | null = null
let hostUp = false
let consecutive5xx = 0
const MAX_CONSECUTIVE_5XX = 3
// Print a progress dot every N ms while waiting so the terminal isn't silent
const TICKER_INTERVAL_MS = 5_000
let lastTickAt = startedAt
const elapsed = () => `${Math.round((Date.now() - startedAt) / 1000)}s`
while (Date.now() < deadline) {
try {
// Give the packaged host enough time to finish a cold /api/boot render.
const response = await requestLocalJson(`${url}/api/boot`, 45_000, authToken)
if (response.statusCode >= 200 && response.statusCode < 300) {
if (!hostUp) {
hostUp = true
stderr?.write(`[gsd] Web host ready.\n`)
}
consecutive5xx = 0
// Host responded successfully — it's ready for the browser
return
} else if (response.statusCode >= 500) {
consecutive5xx++
lastError = `http ${response.statusCode}`
lastBody = response.body || null
if (consecutive5xx >= MAX_CONSECUTIVE_5XX) {
const detail = lastBody ? `: ${lastBody.slice(0, 500)}` : ''
throw new Error(
`boot route returned ${MAX_CONSECUTIVE_5XX} consecutive 5xx responses (last: ${response.statusCode})${detail}`,
)
}
} else {
consecutive5xx = 0
lastError = `http ${response.statusCode}`
}
} catch (error) {
if (error instanceof Error && error.message.startsWith('boot route returned')) {
throw error
}
// Connection refused, timeout, etc. — transient during cold start
consecutive5xx = 0
lastError = error instanceof Error ? error.message : String(error)
}
// Emit a heartbeat line every TICKER_INTERVAL_MS to show we're alive
const now = Date.now()
if (now - lastTickAt >= TICKER_INTERVAL_MS) {
lastTickAt = now
if (hostUp) {
stderr?.write(`[gsd] Still waiting… (${elapsed()})\n`)
} else {
stderr?.write(`[gsd] Waiting for web host… (${elapsed()})\n`)
}
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
throw new Error(lastError ?? 'timed out waiting for boot readiness')
}
/**
* If a previous web server instance is registered for the same `cwd`, attempt
* to kill it and remove its registry entry so the new launch can bind the port
* cleanly. This handles the "orphan process" scenario where a prior `gsd --web`
* was terminated without clean shutdown (e.g. terminal closed).
*/
function cleanupStaleInstance(cwd: string, stderr: WritableLike, registryPath?: string): void {
const registry = readInstanceRegistry(registryPath)
const key = resolve(cwd)
const stale = registry[key]
if (!stale) return
stderr.write(`[gsd] Cleaning up stale web server for ${key} (pid=${stale.pid}, port=${stale.port})…\n`)
const result = killPid(stale.pid)
if (result === 'killed') {
stderr.write(`[gsd] Killed stale web server (pid=${stale.pid}).\n`)
} else if (result === 'already-dead') {
stderr.write(`[gsd] Stale web server was already stopped (pid=${stale.pid}) — clearing entry.\n`)
} else {
stderr.write(`[gsd] Could not kill stale web server (pid=${stale.pid}): ${result.error}\n`)
}
unregisterInstance(cwd, registryPath)
}
export async function launchWebMode(
options: WebModeLaunchOptions,
deps: WebModeDeps = {},
): Promise<WebModeLaunchStatus> {
const stderr = deps.stderr ?? process.stderr
const host = options.host ?? DEFAULT_HOST
const resolution = resolveWebHostBootstrap({
packageRoot: options.packageRoot,
existsSync: deps.existsSync,
})
if (!resolution.ok) {
const failure: WebModeLaunchFailure = {
mode: 'web',
ok: false,
cwd: options.cwd,
projectSessionsDir: options.projectSessionsDir,
host,
port: null,
url: null,
hostKind: 'unresolved',
hostPath: null,
hostRoot: null,
failureReason: `${resolution.reason}; checked=${resolution.candidates.join(',')}`,
candidates: resolution.candidates,
}
emitLaunchStatus(stderr, failure)
return failure
}
stderr.write(`[gsd] Starting web mode…\n`)
// Kill any stale server instance for this project before reserving a port.
// This prevents EADDRINUSE when the previous `gsd --web` was terminated
// without a clean shutdown (e.g. terminal closed, crash).
cleanupStaleInstance(options.cwd, stderr, deps.registryPath)
const port = options.port ?? await (deps.resolvePort ?? reserveWebPort)(host)
const authToken = randomBytes(32).toString('hex')
const url = `http://${host}:${port}`
const env = {
...(deps.env ?? process.env),
HOSTNAME: host,
PORT: String(port),
GSD_WEB_HOST: host,
GSD_WEB_PORT: String(port),
GSD_WEB_AUTH_TOKEN: authToken,
GSD_WEB_PROJECT_CWD: options.cwd,
GSD_WEB_PROJECT_SESSIONS_DIR: options.projectSessionsDir,
GSD_WEB_PACKAGE_ROOT: resolution.packageRoot,
GSD_WEB_HOST_KIND: resolution.kind,
...(resolution.kind === 'source-dev' ? { NEXT_PUBLIC_GSD_DEV: '1' } : {}),
...(options.allowedOrigins?.length ? { GSD_WEB_ALLOWED_ORIGINS: options.allowedOrigins.join(',') } : {}),
}
try {
stderr.write(`[gsd] Initialising resources…\n`)
const bootstrap = deps.initResources ? { initResources: deps.initResources } : await loadResourceBootstrap()
bootstrap.initResources(options.agentDir)
} catch (error) {
const failure: WebModeLaunchFailure = {
mode: 'web',
ok: false,
cwd: options.cwd,
projectSessionsDir: options.projectSessionsDir,
host,
port,
url,
hostKind: resolution.kind,
hostPath: resolution.entryPath,
hostRoot: resolution.hostRoot,
failureReason: `bootstrap:${error instanceof Error ? error.message : String(error)}`,
}
emitLaunchStatus(stderr, failure)
return failure
}
const spawnSpec = buildSpawnSpec(
resolution,
host,
port,
deps.platform ?? process.platform,
deps.execPath ?? process.execPath,
)
stderr.write(`[gsd] Launching web host on port ${port}\n`)
const spawnResult = await spawnDetachedProcess(
deps.spawn ?? ((command, args, spawnOptions) => spawn(command, args, spawnOptions)),
spawnSpec.command,
spawnSpec.args,
{
cwd: spawnSpec.cwd,
detached: true,
stdio: 'ignore',
windowsHide: true,
env,
},
)
if (!spawnResult.ok) {
const failure: WebModeLaunchFailure = {
mode: 'web',
ok: false,
cwd: options.cwd,
projectSessionsDir: options.projectSessionsDir,
host,
port,
url,
hostKind: resolution.kind,
hostPath: resolution.entryPath,
hostRoot: resolution.hostRoot,
failureReason: `launch:${spawnResult.error instanceof Error ? spawnResult.error.message : String(spawnResult.error)}`,
}
emitLaunchStatus(stderr, failure)
return failure
}
try {
const bootReadyFn = deps.waitForBootReady ?? ((u: string) => waitForBootReady(u, 180_000, stderr, authToken))
await bootReadyFn(url)
} catch (error) {
const failure: WebModeLaunchFailure = {
mode: 'web',
ok: false,
cwd: options.cwd,
projectSessionsDir: options.projectSessionsDir,
host,
port,
url,
hostKind: resolution.kind,
hostPath: resolution.entryPath,
hostRoot: resolution.hostRoot,
failureReason: `boot-ready:${error instanceof Error ? error.message : String(error)}`,
}
emitLaunchStatus(stderr, failure)
return failure
}
try {
spawnResult.child.unref?.()
const pid = spawnResult.child.pid
if (pid !== undefined) {
const pidFilePath = deps.pidFilePath ?? defaultWebPidFilePath
;(deps.writePidFile ?? writePidFile)(pidFilePath, pid)
// Register in multi-instance registry
registerInstance(options.cwd, { pid, port, url }, deps.registryPath)
}
const authenticatedUrl = `${url}/#token=${authToken}`
try {
;(deps.openBrowser ?? openBrowser)(authenticatedUrl)
} catch (browserError) {
stderr.write(`[gsd] Could not open browser: ${browserError instanceof Error ? browserError.message : String(browserError)}\n`)
}
} catch (error) {
const failure: WebModeLaunchFailure = {
mode: 'web',
ok: false,
cwd: options.cwd,
projectSessionsDir: options.projectSessionsDir,
host,
port,
url,
hostKind: resolution.kind,
hostPath: resolution.entryPath,
hostRoot: resolution.hostRoot,
failureReason: `browser-open:${error instanceof Error ? error.message : String(error)}`,
}
emitLaunchStatus(stderr, failure)
return failure
}
const authenticatedUrl = `${url}/#token=${authToken}`
const success: WebModeLaunchSuccess = {
mode: 'web',
ok: true,
cwd: options.cwd,
projectSessionsDir: options.projectSessionsDir,
host,
port,
url,
hostKind: resolution.kind,
hostPath: resolution.entryPath,
hostRoot: resolution.hostRoot,
}
stderr.write(`[gsd] Ready → ${authenticatedUrl}\n`)
emitLaunchStatus(stderr, success)
return success
}