/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import assert from 'node:assert'; import type {ChildProcessByStdio} from 'node:child_process'; import {spawn} from 'node:child_process'; import type {Readable, Writable} from 'node:stream'; import {describe, it} from 'node:test'; import {executablePath} from 'puppeteer'; type Server = ChildProcessByStdio; // Once shutdown is signalled, the server should be fully gone within this // budget. The actual fast path is well under 500ms; the budget is set to be // generous against CI noise without being so loose that it would hide a hang. const SHUTDOWN_BUDGET_MS = 3000; // Outer test timeout. If exit doesn't happen within this, treat as a hang // (the bug we're guarding against) and SIGKILL the subprocess. const EXIT_TIMEOUT_MS = 15000; async function spawnServer(): Promise { const child = spawn( 'node', [ 'build/src/bin/chrome-devtools-mcp.js', '--headless', '--isolated', '--executable-path', await executablePath(), ], { env: { ...process.env, CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true', }, stdio: ['pipe', 'pipe', 'pipe'], }, ) as Server; // Drain stderr to avoid pipe-buffer backpressure stalling the server. child.stderr.on('data', () => { // discard }); return child; } async function waitForExit( child: Server, timeoutMs: number, ): Promise<{ code: number | null; signal: NodeJS.Signals | null; elapsedMs: number; }> { const start = Date.now(); return await new Promise((resolve, reject) => { const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error(`server did not exit within ${timeoutMs}ms`)); }, timeoutMs); child.once('exit', (code, signal) => { clearTimeout(timer); resolve({code, signal, elapsedMs: Date.now() - start}); }); }); } async function rpc( child: Server, msg: {method: string; params?: unknown}, ): Promise { const id = Math.floor(Math.random() * 1e9); const payload = JSON.stringify({jsonrpc: '2.0', id, ...msg}) + '\n'; return await new Promise((resolve, reject) => { let buf = ''; const onData = (chunk: Buffer) => { buf += chunk.toString(); const lines = buf.split('\n'); buf = lines.pop() ?? ''; for (const line of lines) { if (!line.trim()) { continue; } try { const parsed = JSON.parse(line) as {id?: number}; if (parsed.id === id) { child.stdout.off('data', onData); resolve(parsed); return; } } catch { // Not a JSON message; ignore. } } }; child.stdout.on('data', onData); const onExit = () => { child.stdout.off('data', onData); reject(new Error('server exited before RPC response')); }; child.once('exit', onExit); child.stdin.write(payload); }); } function notify(child: Server, msg: {method: string; params?: unknown}): void { child.stdin.write(JSON.stringify({jsonrpc: '2.0', ...msg}) + '\n'); } async function initializeAndLaunchBrowser(child: Server): Promise { await rpc(child, { method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: {name: 'shutdown-test', version: '0.0.1'}, }, }); notify(child, {method: 'notifications/initialized'}); // navigate_page forces a real Chrome launch — this is what reproduces // the hang in #2116. Without an active Chrome subprocess, stdin EOF // would close the event loop on its own and shutdown would look fine // even with broken handlers. await rpc(child, { method: 'tools/call', params: { name: 'navigate_page', arguments: {url: 'about:blank'}, }, }); } describe('shutdown', () => { it('exits within budget on stdin EOF after Chrome launch', async () => { const child = await spawnServer(); await initializeAndLaunchBrowser(child); child.stdin.end(); const {elapsedMs} = await waitForExit(child, EXIT_TIMEOUT_MS); assert.ok( elapsedMs < SHUTDOWN_BUDGET_MS, `stdin-EOF shutdown took ${elapsedMs}ms (budget ${SHUTDOWN_BUDGET_MS}ms)`, ); }); it('exits within budget on SIGTERM after Chrome launch', async () => { const child = await spawnServer(); await initializeAndLaunchBrowser(child); child.kill('SIGTERM'); const {elapsedMs} = await waitForExit(child, EXIT_TIMEOUT_MS); assert.ok( elapsedMs < SHUTDOWN_BUDGET_MS, `SIGTERM shutdown took ${elapsedMs}ms (budget ${SHUTDOWN_BUDGET_MS}ms)`, ); }); it('exits within budget on SIGINT after Chrome launch', async () => { const child = await spawnServer(); await initializeAndLaunchBrowser(child); child.kill('SIGINT'); const {elapsedMs} = await waitForExit(child, EXIT_TIMEOUT_MS); assert.ok( elapsedMs < SHUTDOWN_BUDGET_MS, `SIGINT shutdown took ${elapsedMs}ms (budget ${SHUTDOWN_BUDGET_MS}ms)`, ); }); it('exits within budget on SIGHUP after Chrome launch', async () => { const child = await spawnServer(); await initializeAndLaunchBrowser(child); child.kill('SIGHUP'); const {elapsedMs} = await waitForExit(child, EXIT_TIMEOUT_MS); assert.ok( elapsedMs < SHUTDOWN_BUDGET_MS, `SIGHUP shutdown took ${elapsedMs}ms (budget ${SHUTDOWN_BUDGET_MS}ms)`, ); }); });