diff --git a/browse/src/bun-polyfill.cjs b/browse/src/bun-polyfill.cjs index e0ada11b3a..ddda68673d 100644 --- a/browse/src/bun-polyfill.cjs +++ b/browse/src/bun-polyfill.cjs @@ -91,6 +91,7 @@ globalThis.Bun = { stdio, env: options.env, cwd: options.cwd, + windowsHide: true, }); return { diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 59327b7923..094d0a7764 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -143,7 +143,7 @@ async function killServer(pid: number): Promise { try { Bun.spawnSync( ['taskkill', '/PID', String(pid), '/T', '/F'], - { stdout: 'pipe', stderr: 'pipe', timeout: 5000 } + { stdout: 'pipe', stderr: 'pipe', timeout: 5000, windowsHide: true } ); } catch (err: any) { if (err?.code !== 'ENOENT') throw err; @@ -323,9 +323,9 @@ async function startServer(extraEnv?: Record): Promise { } } +async function printPassiveStatus(): Promise { + const state = readState(); + if (!state) { + console.log('Status: stopped'); + return; + } + + try { + const resp = await fetch(`http://127.0.0.1:${state.port}/status`, { + headers: { 'Authorization': `Bearer ${state.token}` }, + signal: AbortSignal.timeout(2000), + }); + if (resp.ok) { + const body = await resp.json() as any; + console.log([ + `Status: ${body.status || 'unknown'}`, + `Mode: ${body.mode || state.mode || 'unknown'}`, + `URL: ${body.url || '(unknown)'}`, + `Tabs: ${body.tabs ?? '?'}`, + `PID: ${body.pid || state.pid}`, + ].join('\n')); + return; + } + } catch { + // Fall through to stopped/unavailable summary. + } + + console.log([ + 'Status: stopped', + `PID: ${state.pid}`, + `Port: ${state.port}`, + 'Reason: daemon is not responding', + ].join('\n')); +} + +async function stopExistingServer(): Promise { + const state = readState(); + if (!state) { + console.log('No browse server running.'); + return; + } + + if (await isServerHealthy(state.port)) { + try { + await fetch(`http://127.0.0.1:${state.port}/command`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${state.token}`, + }, + body: JSON.stringify({ command: 'stop', args: [] }), + signal: AbortSignal.timeout(1000), + }); + } catch { + // The daemon often exits before it can return the HTTP response. + } + await Bun.sleep(500); + } + + if (state.pid && isProcessAlive(state.pid)) { + await killServer(state.pid); + } + + await killOrphanChromium(); + cleanChromiumProfileLocks(); + safeUnlinkQuiet(config.stateFile); + console.log('Browse server stopped.'); +} + /** * Extract `--tab-id ` from args and return { tabId, args } with the flag stripped. * Used by make-pdf's tab-scoped flow: every browse command (newtab, load-html, js, @@ -1039,6 +1108,17 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: const command = args[0]; const commandArgs = args.slice(1); + // Passive lifecycle commands must not create a daemon. + if (command === 'status') { + await printPassiveStatus(); + process.exit(0); + } + + if (command === 'stop') { + await stopExistingServer(); + process.exit(0); + } + // ─── Headed Connect (pre-server command) ──────────────────── // connect must be handled BEFORE ensureServer() because it needs // to restart the server in headed mode with the Chrome extension. diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index 66328432a8..834c1e8cc7 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -529,6 +529,7 @@ async function dpapiDecrypt(encryptedBytes: Buffer): Promise { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', + windowsHide: true, }); proc.stdin.write(encryptedBytes.toString('base64')); @@ -779,6 +780,7 @@ function isBrowserRunning(browserName: string): Promise { return new Promise((resolve) => { const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], { stdout: 'pipe', stderr: 'pipe', + windowsHide: true, }); proc.exited.then(async () => { const out = await new Response(proc.stdout).text(); @@ -869,7 +871,7 @@ export async function importCookiesViaCdp( '--disable-extensions', '--disable-sync', '--no-default-browser-check', - ], { stdout: 'pipe', stderr: 'pipe' }); + ], { stdout: 'pipe', stderr: 'pipe', windowsHide: true }); // Wait for Chrome to start, then find a page target's WebSocket URL. // Network.getAllCookies is only available on page targets, not browser. diff --git a/browse/src/server.ts b/browse/src/server.ts index 301781acce..9d5ba362eb 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -5,7 +5,7 @@ * Bun.serve HTTP on localhost → routes commands to Playwright * Console/network/dialog buffers: CircularBuffer in-memory + async disk flush * Chromium crash → server EXITS with clear error (CLI auto-restarts) - * Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min) + * Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min, 5 min on Windows) * * State: * State file: /.gstack/browse.json (set via BROWSE_STATE_FILE env) @@ -140,7 +140,8 @@ function sanitizeAuthToken(raw: string | undefined): string | null { // and factory-scoped validateAuth closes over the same value. start() reads // env once via resolveConfigFromEnv() and threads the result through. const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10); -const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min +const DEFAULT_IDLE_TIMEOUT_MS = process.platform === 'win32' ? 300_000 : 1_800_000; +const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || String(DEFAULT_IDLE_TIMEOUT_MS), 10); /** * Port the local listener bound to. Set once the daemon picks a port. @@ -1806,6 +1807,28 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { }); } + // Status snapshot — auth required, does NOT reset idle timer. + if (url.pathname === '/status' && req.method === 'GET') { + if (!validateAuth(req)) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + const healthy = await browserManager.isHealthy(); + return new Response(JSON.stringify({ + status: healthy ? 'healthy' : 'unhealthy', + mode: browserManager.getConnectionMode(), + uptime: Math.floor((Date.now() - startTime) / 1000), + url: browserManager.getCurrentUrl(), + tabs: browserManager.getTabCount(), + pid: process.pid, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + // ─── /pty-session — mint sessionId + lease + attachToken ───────── // // v1.44+ four-tuple shape: diff --git a/browse/test/cli-lifecycle.test.ts b/browse/test/cli-lifecycle.test.ts new file mode 100644 index 0000000000..85780509b4 --- /dev/null +++ b/browse/test/cli-lifecycle.test.ts @@ -0,0 +1,67 @@ +import { describe, test, expect } from 'bun:test'; +import { spawn } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +function cliEnv(stateFile: string): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) env[key] = value; + } + env.BROWSE_STATE_FILE = stateFile; + return env; +} + +function runCli(args: string[], stateFile: string): Promise<{ code: number; stdout: string; stderr: string }> { + const cliPath = path.resolve(__dirname, '../src/cli.ts'); + return new Promise((resolve) => { + const proc = spawn('bun', ['run', cliPath, ...args], { + timeout: 15_000, + env: cliEnv(stateFile), + }); + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (data) => stdout += data.toString()); + proc.stderr.on('data', (data) => stderr += data.toString()); + proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr })); + }); +} + +describe('CLI lifecycle commands', () => { + test('status with no state does not start a daemon', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cli-lifecycle-')); + const stateFile = path.join(dir, 'browse.json'); + try { + const result = await runCli(['status'], stateFile); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('Status: stopped'); + expect(result.stderr).not.toContain('Starting server'); + expect(fs.existsSync(stateFile)).toBe(false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 20_000); + + test('status on a dead state file does not start a daemon', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cli-lifecycle-')); + const stateFile = path.join(dir, 'browse.json'); + try { + fs.writeFileSync(stateFile, JSON.stringify({ + port: 1, + token: 'fake', + pid: 999999, + })); + + const result = await runCli(['status'], stateFile); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('Status: stopped'); + expect(result.stdout).toContain('Reason: daemon is not responding'); + expect(result.stderr).not.toContain('Starting server'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 20_000); +}); diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts index 9382cb27ed..61bac692a1 100644 --- a/browse/test/commands.test.ts +++ b/browse/test/commands.test.ts @@ -14,7 +14,6 @@ import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands import { handleMetaCommand } from '../src/meta-commands'; import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, CircularBuffer } from '../src/buffers'; import * as fs from 'fs'; -import { spawn } from 'child_process'; import * as path from 'path'; // Thin wrappers that bridge old test calls (bm as 3rd arg) to new signatures (session + bm) @@ -862,50 +861,6 @@ describe('CLI server script resolution', () => { }); }); -// ─── CLI lifecycle ────────────────────────────────────────────── - -describe('CLI lifecycle', () => { - test('dead state file triggers a clean restart', async () => { - const stateFile = `/tmp/browse-test-state-${Date.now()}.json`; - fs.writeFileSync(stateFile, JSON.stringify({ - port: 1, - token: 'fake', - pid: 999999, - })); - - const cliPath = path.resolve(__dirname, '../src/cli.ts'); - const cliEnv: Record = {}; - for (const [k, v] of Object.entries(process.env)) { - if (v !== undefined) cliEnv[k] = v; - } - cliEnv.BROWSE_STATE_FILE = stateFile; - const result = await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => { - const proc = spawn('bun', ['run', cliPath, 'status'], { - timeout: 15000, - env: cliEnv, - }); - let stdout = ''; - let stderr = ''; - proc.stdout.on('data', (d) => stdout += d.toString()); - proc.stderr.on('data', (d) => stderr += d.toString()); - proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr })); - }); - - let restartedPid: number | null = null; - if (fs.existsSync(stateFile)) { - restartedPid = JSON.parse(fs.readFileSync(stateFile, 'utf-8')).pid; - fs.unlinkSync(stateFile); - } - if (restartedPid) { - try { process.kill(restartedPid, 'SIGTERM'); } catch {} - } - - expect(result.code).toBe(0); - expect(result.stdout).toContain('Status: healthy'); - expect(result.stderr).toContain('Starting server'); - }, 20000); -}); - // ─── Buffer bounds ────────────────────────────────────────────── describe('Buffer bounds', () => {

AltStyle によって変換されたページ (->オリジナル) /