diff --git a/profiler-cli/src/client.ts b/profiler-cli/src/client.ts index b1c3c17661..b343797b50 100644 --- a/profiler-cli/src/client.ts +++ b/profiler-cli/src/client.ts @@ -22,10 +22,10 @@ import { getCurrentSessionId, getCurrentSocketPath, getSocketPath, - isProcessRunning, + isDaemonReachable, loadSessionMetadata, validateSession, - waitForProcessExit, + waitForSocketClose, } from './session'; import { BUILD_HASH } from './constants'; @@ -77,17 +77,8 @@ async function sendMessageToSocket( async function attemptShutdownOnBuildMismatch( sessionDir: string, sessionId: string, - socketPath: string, - pid: number + socketPath: string ): Promise { - if (process.platform !== 'win32' && !fs.existsSync(socketPath)) { - if (!isProcessRunning(pid)) { - cleanupSession(sessionDir, sessionId); - return 'already-dead'; - } - return 'still-running'; - } - try { const response = await sendMessageToSocket( socketPath, @@ -99,10 +90,12 @@ async function attemptShutdownOnBuildMismatch( console.error( `Failed to stop mismatched daemon for session ${sessionId}: unexpected response ${response.type}` ); - return isProcessRunning(pid) ? 'still-running' : 'already-dead'; + return (await isDaemonReachable(socketPath)) + ? 'still-running' + : 'already-dead'; } - const exited = await waitForProcessExit(pid); + const exited = await waitForSocketClose(socketPath); if (!exited) { console.error( `Mismatched daemon for session ${sessionId} acknowledged shutdown but did not exit within timeout` @@ -113,7 +106,7 @@ async function attemptShutdownOnBuildMismatch( cleanupSession(sessionDir, sessionId); return 'stopped'; } catch (error) { - if (!isProcessRunning(pid)) { + if (!(await isDaemonReachable(socketPath))) { cleanupSession(sessionDir, sessionId); return 'already-dead'; } @@ -140,7 +133,7 @@ async function sendRawMessage( } // Validate the session - if (!validateSession(sessionDir, resolvedSessionId)) { + if (!(await validateSession(sessionDir, resolvedSessionId))) { cleanupSession(sessionDir, resolvedSessionId); throw new Error( `Session ${resolvedSessionId} is not running or is invalid.` @@ -153,8 +146,7 @@ async function sendRawMessage( const shutdownResult = await attemptShutdownOnBuildMismatch( sessionDir, resolvedSessionId, - metadata.socketPath, - metadata.pid + metadata.socketPath ); const shutdownMessage = @@ -250,7 +242,7 @@ export async function startNewDaemon( const targetSessionId = sessionId || generateSessionId(); if (sessionId) { - const existingSession = validateSession(sessionDir, targetSessionId); + const existingSession = await validateSession(sessionDir, targetSessionId); if (existingSession) { throw new Error( `Session ${targetSessionId} is already running. Stop it first or choose a different session id.` @@ -266,6 +258,9 @@ export async function startNewDaemon( const scriptPath = process.argv[1]; const daemonArgs = [ + // Make fetch respect HTTP_PROXY/HTTPS_PROXY/NO_PROXY. This is the default + // in a lot of tools like, curl, python, go etc. + '--use-env-proxy', scriptPath, '--daemon', absolutePath, @@ -299,14 +294,14 @@ export async function startNewDaemon( attempts++; // Validate the session (checks metadata exists, process running, socket exists) - if (validateSession(sessionDir, targetSessionId)) { + if (await validateSession(sessionDir, targetSessionId)) { // Daemon is validated and running break; } } // Check if daemon started successfully after polling - if (!validateSession(sessionDir, targetSessionId)) { + if (!(await validateSession(sessionDir, targetSessionId))) { throw new Error( `Failed to start daemon: session not validated after ${daemonStartMaxAttempts * 50}ms` ); diff --git a/profiler-cli/src/commands/session.ts b/profiler-cli/src/commands/session.ts index 6ad3e4f4f4..889c1d3ad0 100644 --- a/profiler-cli/src/commands/session.ts +++ b/profiler-cli/src/commands/session.ts @@ -27,13 +27,13 @@ export function registerSessionCommand( session .command('list', { isDefault: true }) .description('List all running daemon sessions') - .action(() => { + .action(async () => { const sessionIds = listSessions(sessionDir); let numCleaned = 0; const runningSessionMetadata = []; for (const sessionId of sessionIds) { - const metadata = validateSession(sessionDir, sessionId); + const metadata = await validateSession(sessionDir, sessionId); if (metadata === null) { cleanupSession(sessionDir, sessionId); numCleaned++; @@ -70,8 +70,8 @@ export function registerSessionCommand( session .command('use ') .description('Switch the current session') - .action((sessionId: string) => { - const metadata = validateSession(sessionDir, sessionId); + .action(async (sessionId: string) => { + const metadata = await validateSession(sessionDir, sessionId); if (metadata === null) { console.error(`Error: session "${sessionId}" not found or not running`); process.exit(1); diff --git a/profiler-cli/src/session.ts b/profiler-cli/src/session.ts index bb746db959..8ebbbac4ab 100644 --- a/profiler-cli/src/session.ts +++ b/profiler-cli/src/session.ts @@ -12,6 +12,7 @@ */ import * as fs from 'fs'; +import * as net from 'net'; import * as path from 'path'; import * as crypto from 'crypto'; import type { SessionMetadata } from './protocol'; @@ -141,36 +142,45 @@ export function getCurrentSocketPath(sessionDir: string): string | null { } /** - * Check if a process is running. + * Check if a daemon is reachable by attempting a socket connection. + * Works for both Unix domain sockets and Windows named pipes. */ -export function isProcessRunning(pid: number): boolean { - try { - // Sending signal 0 checks if process exists without killing it - process.kill(pid, 0); - return true; - } catch (_error) { - return false; - } +export async function isDaemonReachable(socketPath: string): Promise { + return new Promise((resolve) => { + const socket = net.connect(socketPath); + socket.setTimeout(1000); + socket.on('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => { + resolve(false); + }); + socket.on('timeout', () => { + socket.destroy(); + resolve(false); + }); + }); } /** - * Wait for a process to exit. + * Wait for a daemon's socket to become unreachable (i.e. for the daemon to stop). */ -export async function waitForProcessExit( - pid: number, +export async function waitForSocketClose( + socketPath: string, timeoutMs: number = 5000, pollIntervalMs: number = 50 ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - if (!isProcessRunning(pid)) { + if (!(await isDaemonReachable(socketPath))) { return true; } await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); } - return !isProcessRunning(pid); + return !(await isDaemonReachable(socketPath)); } /** @@ -202,25 +212,19 @@ export function cleanupSession(sessionDir: string, sessionId: string): void { } /** - * Validate that a session is healthy (process running, socket exists). + * Validate that a session is healthy (daemon reachable via socket). * If not, clean up stale files. */ -export function validateSession( +export async function validateSession( sessionDir: string, sessionId: string -): SessionMetadata | null { +): Promise { const metadata = loadSessionMetadata(sessionDir, sessionId); if (!metadata) { return null; } - // Check if process is still running - if (!isProcessRunning(metadata.pid)) { - return null; - } - - // Check if socket exists (Unix only — named pipes on Windows are not filesystem files) - if (process.platform !== 'win32' && !fs.existsSync(metadata.socketPath)) { + if (!(await isDaemonReachable(metadata.socketPath))) { return null; } diff --git a/profiler-cli/src/test/unit/session.test.ts b/profiler-cli/src/test/unit/session.test.ts index 984b914ecc..c2144f20c5 100644 --- a/profiler-cli/src/test/unit/session.test.ts +++ b/profiler-cli/src/test/unit/session.test.ts @@ -12,9 +12,9 @@ */ import * as fs from 'fs'; +import * as net from 'net'; import * as path from 'path'; import * as os from 'os'; -import { spawn } from 'child_process'; import { ensureSessionDir, generateSessionId, @@ -27,8 +27,8 @@ import { setCurrentSession, getCurrentSessionId, getCurrentSocketPath, - isProcessRunning, - waitForProcessExit, + isDaemonReachable, + waitForSocketClose, cleanupSession, validateSession, listSessions, @@ -235,38 +235,47 @@ describe('profiler-cli session management', function () { }); }); - describe('isProcessRunning', function () { - it('returns true for current process', function () { - expect(isProcessRunning(process.pid)).toBe(true); + describe('isDaemonReachable', function () { + it('returns false when nothing is listening', async function () { + const socketPath = getSocketPath(testSessionDir, 'test-socket'); + expect(await isDaemonReachable(socketPath)).toBe(false); }); - it('returns false for non-existent PID', function () { - expect(isProcessRunning(999999)).toBe(false); + it('returns true when a server is listening', async function () { + const socketPath = getSocketPath(testSessionDir, 'test-socket'); + const server = net.createServer(); + await new Promise((resolve) => server.listen(socketPath, resolve)); + + try { + expect(await isDaemonReachable(socketPath)).toBe(true); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } }); + }); - it('waits for a process to exit', async function () { - const child = spawn(process.execPath, [ - '-e', - 'setTimeout(() => process.exit(0), 100)', - ]); + describe('waitForSocketClose', function () { + it('returns true when the server closes', async function () { + const socketPath = getSocketPath(testSessionDir, 'test-socket'); + const server = net.createServer(); + await new Promise((resolve) => server.listen(socketPath, resolve)); - const exited = await waitForProcessExit(child.pid!, 2000, 10); + setTimeout(() => server.close(), 100); - expect(exited).toBe(true); + const closed = await waitForSocketClose(socketPath, 2000, 10); + expect(closed).toBe(true); }); - it('times out if a process does not exit', async function () { - const child = spawn(process.execPath, [ - '-e', - 'setTimeout(() => process.exit(0), 5000)', - ]); + it('times out if the server does not close', async function () { + const socketPath = getSocketPath(testSessionDir, 'test-socket'); + const server = net.createServer(); + await new Promise((resolve) => server.listen(socketPath, resolve)); try { - const exited = await waitForProcessExit(child.pid!, 50, 10); - expect(exited).toBe(false); + const closed = await waitForSocketClose(socketPath, 50, 10); + expect(closed).toBe(false); } finally { - child.kill('SIGTERM'); - await waitForProcessExit(child.pid!, 2000, 10); + await new Promise((resolve) => server.close(() => resolve())); } }); }); @@ -320,70 +329,51 @@ describe('profiler-cli session management', function () { }); describe('validateSession', function () { - it('returns false for non-existent session', function () { - expect(validateSession(testSessionDir, 'nonexistent')).toBe(null); + it('returns null for non-existent session', async function () { + expect(await validateSession(testSessionDir, 'nonexistent')).toBe(null); }); - it('returns false for session with dead PID', function () { + it('returns null when nothing is listening on the socket', async function () { const sessionId = 'test123'; const metadata: SessionMetadata = { id: sessionId, socketPath: getSocketPath(testSessionDir, sessionId), logPath: getLogPath(testSessionDir, sessionId), - pid: 999999, // Non-existent PID + pid: process.pid, profilePath: '/path/to/profile.json', createdAt: new Date().toISOString(), buildHash: TEST_BUILD_HASH, }; saveSessionMetadata(testSessionDir, metadata); + // Intentionally don't start a server - expect(validateSession(testSessionDir, sessionId)).toBe(null); + expect(await validateSession(testSessionDir, sessionId)).toBe(null); }); - it('returns false for session with missing socket', function () { - if (process.platform === 'win32') { - // Not applicable on Windows: named pipes are self-cleaning and disappear - // automatically when the server stops, so a session can't have a live PID - // but a missing socket. validateSession skips the socket check on Windows - // for this reason. - return; - } + it('returns metadata for a valid session with an active socket', async function () { const sessionId = 'test123'; + const socketPath = getSocketPath(testSessionDir, sessionId); const metadata: SessionMetadata = { id: sessionId, - socketPath: getSocketPath(testSessionDir, sessionId), + socketPath, logPath: getLogPath(testSessionDir, sessionId), - pid: process.pid, // Use current process PID (guaranteed to exist) + pid: process.pid, profilePath: '/path/to/profile.json', createdAt: new Date().toISOString(), buildHash: TEST_BUILD_HASH, }; saveSessionMetadata(testSessionDir, metadata); - // Intentionally don't create socket file - - expect(validateSession(testSessionDir, sessionId)).toBe(null); - }); - it('returns true for valid session', function () { - const sessionId = 'test123'; - const metadata: SessionMetadata = { - id: sessionId, - socketPath: getSocketPath(testSessionDir, sessionId), - logPath: getLogPath(testSessionDir, sessionId), - pid: process.pid, // Use current process PID - profilePath: '/path/to/profile.json', - createdAt: new Date().toISOString(), - buildHash: TEST_BUILD_HASH, - }; + const server = net.createServer(); + await new Promise((resolve) => server.listen(socketPath, resolve)); - saveSessionMetadata(testSessionDir, metadata); - if (process.platform !== 'win32') { - fs.writeFileSync(metadata.socketPath, ''); + try { + expect(await validateSession(testSessionDir, sessionId)).not.toBe(null); + } finally { + await new Promise((resolve) => server.close(() => resolve())); } - - expect(validateSession(testSessionDir, sessionId)).not.toBe(null); }); });