Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 16 additions & 21 deletions profiler-cli/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ import {
getCurrentSessionId,
getCurrentSocketPath,
getSocketPath,
isProcessRunning,
isDaemonReachable,
loadSessionMetadata,
validateSession,
waitForProcessExit,
waitForSocketClose,
} from './session';
import { BUILD_HASH } from './constants';

Expand Down Expand Up @@ -77,17 +77,8 @@ async function sendMessageToSocket(
async function attemptShutdownOnBuildMismatch(
sessionDir: string,
sessionId: string,
socketPath: string,
pid: number
socketPath: string
): Promise<BuildMismatchShutdownResult> {
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,
Expand All @@ -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`
Expand All @@ -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';
}
Expand All @@ -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.`
Expand All @@ -153,8 +146,7 @@ async function sendRawMessage(
const shutdownResult = await attemptShutdownOnBuildMismatch(
sessionDir,
resolvedSessionId,
metadata.socketPath,
metadata.pid
metadata.socketPath
);

const shutdownMessage =
Expand Down Expand Up @@ -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.`
Expand All @@ -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,
Expand Down Expand Up @@ -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`
);
Expand Down
8 changes: 4 additions & 4 deletions profiler-cli/src/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Expand Down Expand Up @@ -70,8 +70,8 @@ export function registerSessionCommand(
session
.command('use <id>')
.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);
Expand Down
52 changes: 28 additions & 24 deletions profiler-cli/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<boolean> {
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<boolean> {
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));
}

/**
Expand Down Expand Up @@ -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<SessionMetadata | null> {
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;
}

Expand Down
Loading
Loading