Implement cross-platform ProcessUtils for reliable process checking

- Add ProcessUtils class with Windows/Unix process detection methods
- Replace raw process.kill(pid, 0) calls with ProcessUtils.isProcessRunning()
- Support Windows tasklist and Unix kill signal 0 approaches
- Add process killing and waiting utilities for better process management
- Update SessionManager, PtyManager, and PtyService to use ProcessUtils
- Improves reliability of cmd.exe session detection on Windows

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mario Zechner 2025-06-19 03:55:36 +02:00
parent cbc1a4e49d
commit 62f0579d07
5 changed files with 163 additions and 20 deletions

139
web/src/pty/ProcessUtils.ts Normal file
View file

@ -0,0 +1,139 @@
/**
* ProcessUtils - Cross-platform process management utilities
*
* Provides reliable process existence checking across Windows, macOS, and Linux.
*/
import { spawnSync } from 'child_process';
export class ProcessUtils {
/**
* Check if a process is currently running by PID
* Uses platform-appropriate methods for reliable detection
*/
static isProcessRunning(pid: number): boolean {
if (!pid || pid <= 0) {
return false;
}
try {
if (process.platform === 'win32') {
// Windows: Use tasklist command
return ProcessUtils.isProcessRunningWindows(pid);
} else {
// Unix/Linux/macOS: Use kill with signal 0
return ProcessUtils.isProcessRunningUnix(pid);
}
} catch (error) {
console.warn(`Error checking if process ${pid} is running:`, error);
return false;
}
}
/**
* Windows-specific process check using tasklist
*/
private static isProcessRunningWindows(pid: number): boolean {
try {
const result = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'], {
encoding: 'utf8',
windowsHide: true,
timeout: 5000, // 5 second timeout
});
// Check if the command succeeded and PID appears in output
if (result.status === 0 && result.stdout) {
// tasklist outputs CSV format with PID in quotes
return result.stdout.includes(`"${pid}"`);
}
return false;
} catch (error) {
console.warn(`Windows process check failed for PID ${pid}:`, error);
return false;
}
}
/**
* Unix-like systems process check using kill signal 0
*/
private static isProcessRunningUnix(pid: number): boolean {
try {
// Send signal 0 to check if process exists
// This doesn't actually kill the process, just checks existence
process.kill(pid, 0);
return true;
} catch (error) {
// If we get ESRCH, the process doesn't exist
// If we get EPERM, the process exists but we don't have permission
const err = error as NodeJS.ErrnoException;
if (err.code === 'EPERM') {
// Process exists but we don't have permission to signal it
return true;
}
// ESRCH or other errors mean process doesn't exist
return false;
}
}
/**
* Get basic process information if available
* Returns null if process is not running or info cannot be retrieved
*/
static getProcessInfo(pid: number): { pid: number; exists: boolean } | null {
if (!ProcessUtils.isProcessRunning(pid)) {
return null;
}
return {
pid,
exists: true,
};
}
/**
* Kill a process with platform-appropriate method
* Returns true if the kill signal was sent successfully
*/
static killProcess(pid: number, signal: NodeJS.Signals | number = 'SIGTERM'): boolean {
if (!pid || pid <= 0) {
return false;
}
try {
if (process.platform === 'win32') {
// Windows: Use taskkill command for more reliable termination
const result = spawnSync('taskkill', ['/PID', pid.toString(), '/F'], {
windowsHide: true,
timeout: 5000,
});
return result.status === 0;
} else {
// Unix-like: Use built-in process.kill
process.kill(pid, signal);
return true;
}
} catch (error) {
console.warn(`Error killing process ${pid}:`, error);
return false;
}
}
/**
* Wait for a process to exit with timeout
* Returns true if process exited within timeout, false otherwise
*/
static async waitForProcessExit(pid: number, timeoutMs: number = 5000): Promise<boolean> {
const startTime = Date.now();
const checkInterval = 100; // Check every 100ms
while (Date.now() - startTime < timeoutMs) {
if (!ProcessUtils.isProcessRunning(pid)) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, checkInterval));
}
return false;
}
}

View file

@ -21,6 +21,7 @@ import {
} from './types.js';
import { AsciinemaWriter } from './AsciinemaWriter.js';
import { SessionManager } from './SessionManager.js';
import { ProcessUtils } from './ProcessUtils.js';
export class PtyManager {
private sessions = new Map<string, PtySession>();
@ -408,11 +409,7 @@ export class PtyManager {
await new Promise((resolve) => setTimeout(resolve, checkInterval));
// Check if process is still alive
try {
process.kill(pid, 0); // Signal 0 just checks if process exists
// Process still exists, continue waiting
console.log(`Session ${sessionId} still alive after ${(i + 1) * checkInterval}ms...`);
} catch (_error) {
if (!ProcessUtils.isProcessRunning(pid)) {
// Process no longer exists - it terminated gracefully
console.log(
`Session ${sessionId} terminated gracefully after ${(i + 1) * checkInterval}ms`
@ -420,6 +417,9 @@ export class PtyManager {
this.sessions.delete(sessionId);
return;
}
// Process still exists, continue waiting
console.log(`Session ${sessionId} still alive after ${(i + 1) * checkInterval}ms...`);
}
// Process didn't terminate gracefully within 3 seconds, force kill

View file

@ -18,6 +18,7 @@ import {
PtyError,
} from './types.js';
import { PtyManager } from './PtyManager.js';
import { ProcessUtils } from './ProcessUtils.js';
export class PtyService {
private config: PtyConfig;
@ -301,17 +302,16 @@ export class PtyService {
await new Promise((resolve) => setTimeout(resolve, checkInterval));
// Check if process is still alive
try {
process.kill(pid, 0); // Signal 0 just checks if process exists
// Process still exists, continue waiting
console.log(`Session ${sessionId} still alive after ${(i + 1) * checkInterval}ms...`);
} catch (_error) {
if (!ProcessUtils.isProcessRunning(pid)) {
// Process no longer exists - it terminated gracefully
console.log(
`Session ${sessionId} terminated gracefully after ${(i + 1) * checkInterval}ms`
);
return;
}
// Process still exists, continue waiting
console.log(`Session ${sessionId} still alive after ${(i + 1) * checkInterval}ms...`);
}
// Process didn't terminate gracefully within 3 seconds, force kill

View file

@ -9,6 +9,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { SessionInfo, SessionEntryWithId, PtyError } from './types.js';
import { ProcessUtils } from './ProcessUtils.js';
export class SessionManager {
private controlPath: string;
@ -325,15 +326,10 @@ export class SessionManager {
/**
* Check if a process is still running
* Uses cross-platform process detection for reliability
*/
isProcessRunning(pid: number): boolean {
try {
// Send signal 0 to check if process exists
process.kill(pid, 0);
return true;
} catch (_error) {
return false;
}
return ProcessUtils.isProcessRunning(pid);
}
/**
@ -341,10 +337,17 @@ export class SessionManager {
*/
getProcessStatus(pid: number): { isAlive: boolean; isWaiting: boolean } {
try {
// First check if process exists
process.kill(pid, 0);
// First check if process exists using cross-platform method
if (!ProcessUtils.isProcessRunning(pid)) {
return { isAlive: false, isWaiting: false };
}
// Use ps command to get process state like tty-fwd does (Unix only)
if (process.platform === 'win32') {
// On Windows, we can't easily get process state, so assume running
return { isAlive: true, isWaiting: false };
}
// Use ps command to get process state like tty-fwd does
const { spawnSync } = require('child_process');
const result = spawnSync('ps', ['-p', pid.toString(), '-o', 'stat='], {
encoding: 'utf8',

View file

@ -15,6 +15,7 @@ export { PtyService } from './PtyService.js';
export { PtyManager } from './PtyManager.js';
export { AsciinemaWriter } from './AsciinemaWriter.js';
export { SessionManager } from './SessionManager.js';
export { ProcessUtils } from './ProcessUtils.js';
// Re-export for convenience
export { PtyError } from './types.js';