refactor: extract process management utilities for cross-platform support

Consolidate process lifecycle management (kill, check alive) into a new
utility module with proper Windows/Unix handling, replacing scattered
process.kill() calls with consistent async APIs.
This commit is contained in:
weishu
2025-12-26 16:12:03 +08:00
parent eee7a249fa
commit 702072e9ba
13 changed files with 152 additions and 80 deletions
+3 -3
View File
@@ -55,7 +55,7 @@ The daemon detects when CLI binary changes (e.g., after `npm upgrade hapi`):
Every 60 seconds (configurable via `HAPI_DAEMON_HEARTBEAT_INTERVAL`):
1. **Guard**: Skips if previous heartbeat still running (prevents concurrent heartbeats)
2. **Session Pruning**: Checks each tracked PID with `process.kill(pid, 0)`, removes dead sessions
2. **Session Pruning**: Checks each tracked PID with `isProcessAlive(pid)`, removes dead sessions
3. **Version Check**: Compares CLI binary mtime, triggers self-restart if changed
4. **PID Ownership**: Verifies daemon still owns state file, self-terminates if another daemon took over
5. **State Update**: Writes `lastHeartbeat` timestamp to daemon.state.json
@@ -74,7 +74,7 @@ Control Flow:
- Stops HTTP server
- Deletes daemon.state.json
- Releases lock file
5. If HTTP fails, falls back to `process.kill(pid, 'SIGKILL')`
5. If HTTP fails, falls back to `killProcess(pid, true)` (uses `taskkill /T /F` on Windows)
## 2. Multi-Agent Support
@@ -135,7 +135,7 @@ When spawning a session, directory handling:
Via RPC `stop-session` or HTTP `/stop-session`:
1. `stopSession()` finds session by `happySessionId` or `PID-{pid}` format
2. Sends SIGTERM to process (via `childProcess.kill()` or `process.kill(pid)`)
2. Sends termination request via `killProcessByChildProcess()` or `killProcess()` (Windows uses `taskkill /T`)
3. `on('exit')` handler removes from tracking map
## 4. HTTP Control Server (Fastify)
+14 -17
View File
@@ -10,6 +10,7 @@ import packageJson from '../../package.json';
import { existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { isBunCompiled, projectPath } from '@/projectPath';
import { isProcessAlive, killProcess } from '@/utils/process';
export function getInstalledCliMtimeMs(): number | undefined {
if (isBunCompiled()) {
@@ -42,9 +43,7 @@ async function daemonPost(path: string, body?: any): Promise<{ error?: string }
};
}
try {
process.kill(state.pid, 0);
} catch (error) {
if (!isProcessAlive(state.pid)) {
const errorMessage = 'Daemon is not running, file is stale';
logger.debug(`[CONTROL CLIENT] ${errorMessage}`);
return {
@@ -143,14 +142,13 @@ export async function checkIfDaemonRunningAndCleanupStaleState(): Promise<boolea
}
// Check if the daemon is running
try {
process.kill(state.pid, 0);
if (isProcessAlive(state.pid)) {
return true;
} catch {
logger.debug('[DAEMON RUN] Daemon PID not running, cleaning up state');
await cleanupDaemonState();
return false;
}
logger.debug('[DAEMON RUN] Daemon PID not running, cleaning up state');
await cleanupDaemonState();
return false;
}
/**
@@ -239,11 +237,11 @@ export async function stopDaemon() {
}
// Force kill
try {
process.kill(state.pid, 'SIGKILL');
const killed = await killProcess(state.pid, true);
if (killed) {
logger.debug('Force killed daemon');
} catch (error) {
logger.debug('Daemon already dead');
} else {
logger.debug('Daemon already dead or could not be killed');
}
} catch (error) {
logger.debug('Error stopping daemon', error);
@@ -253,12 +251,11 @@ export async function stopDaemon() {
async function waitForProcessDeath(pid: number, timeout: number): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
process.kill(pid, 0);
if (isProcessAlive(pid)) {
await new Promise(resolve => setTimeout(resolve, 100));
} catch {
return; // Process is dead
continue;
}
return; // Process is dead
}
throw new Error('Process did not die within timeout');
}
+18 -20
View File
@@ -32,6 +32,7 @@ import { readDaemonState, clearDaemonState } from '@/persistence';
import { Metadata } from '@/api/types';
import { spawnHappyCLI } from '@/utils/spawnHappyCLI';
import { getLatestDaemonLog } from '@/ui/logger';
import { isProcessAlive, isWindows, killProcess, killProcessByChildProcess } from '@/utils/process';
// Utility to wait for condition
async function waitFor(
@@ -234,7 +235,7 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout:
// Also kill the terminal process directly to be sure
try {
terminalHappyProcess.kill('SIGTERM');
await killProcessByChildProcess(terminalHappyProcess);
} catch (e) {
// Process might already be dead
}
@@ -325,18 +326,13 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout:
const initialLogs = readdirSync(logsDir).filter(f => f.endsWith('-daemon.log'));
// Send SIGKILL to daemon (force kill)
process.kill(daemonPid, 'SIGKILL');
await killProcess(daemonPid, true);
// Wait for process to die
await new Promise(resolve => setTimeout(resolve, 500));
// Check if process is dead
let isDead = false;
try {
process.kill(daemonPid, 0);
} catch {
isDead = true;
}
const isDead = !isProcessAlive(daemonPid);
expect(isDead).toBe(true);
// Check that log file exists (it was created when daemon started)
@@ -350,36 +346,38 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout:
await clearDaemonState();
});
it('should die with cleanup logs when SIGTERM is sent', async () => {
// SIGTERM test - daemon should cleanup gracefully
it('should die with cleanup logs when a graceful shutdown is requested', async () => {
// Graceful shutdown test - daemon should cleanup gracefully
const logFile = await getLatestDaemonLog();
if (!logFile) {
throw new Error('No log file found');
}
// Send SIGTERM to daemon (graceful shutdown)
process.kill(daemonPid, 'SIGTERM');
if (isWindows()) {
// Windows taskkill does not deliver SIGTERM/SIGBREAK to Node handlers.
await stopDaemonHttp();
} else {
// Send SIGTERM to daemon (graceful shutdown)
await killProcess(daemonPid);
}
// Wait for graceful shutdown
await new Promise(resolve => setTimeout(resolve, 4_000));
// Check if process is dead
let isDead = false;
try {
process.kill(daemonPid, 0);
} catch {
isDead = true;
}
const isDead = !isProcessAlive(daemonPid);
expect(isDead).toBe(true);
// Read the log file to check for cleanup messages
const logContent = readFileSync(logFile.path, 'utf8');
// Should contain cleanup messages
expect(logContent).toContain('SIGTERM');
if (!isWindows()) {
expect(logContent).toContain('SIGTERM');
}
expect(logContent).toContain('cleanup');
console.log('[TEST] Daemon terminated gracefully with SIGTERM - cleanup logs written');
console.log('[TEST] Daemon terminated gracefully - cleanup logs written');
// Clean up state file if it still exists (should have been cleaned by SIGTERM handler)
await clearDaemonState();
+12 -20
View File
@@ -6,7 +6,7 @@
*/
import psList from 'ps-list';
import spawn from 'cross-spawn';
import { killProcess } from '@/utils/process';
/**
* Find all HAPI CLI processes (including current process)
@@ -92,25 +92,17 @@ export async function killRunawayHappyProcesses(): Promise<{ killed: number, err
try {
console.log(`Killing runaway process PID ${pid}: ${command}`);
if (process.platform === 'win32') {
// Windows: use taskkill
const result = spawn.sync('taskkill', ['/F', '/PID', pid.toString()], { stdio: 'pipe' });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`taskkill exited with code ${result.status}`);
} else {
// Unix: try SIGTERM first
process.kill(pid, 'SIGTERM');
// Wait a moment
await new Promise(resolve => setTimeout(resolve, 1000));
// Check if still alive
const processes = await psList();
const stillAlive = processes.find(p => p.pid === pid);
if (stillAlive) {
console.log(`Process PID ${pid} ignored SIGTERM, using SIGKILL`);
process.kill(pid, 'SIGKILL');
}
await killProcess(pid, false);
// Wait a moment
await new Promise(resolve => setTimeout(resolve, 1000));
// Check if still alive
const processes = await psList();
const stillAlive = processes.find(p => p.pid === pid);
if (stillAlive) {
console.log(`Process PID ${pid} ignored termination request, using force kill`);
await killProcess(pid, true);
}
console.log(`Successfully killed runaway process PID ${pid}`);
+4
View File
@@ -2,6 +2,10 @@ import { logger } from '@/ui/logger';
import { install as installMac } from './mac/install';
export async function install(): Promise<void> {
if (process.platform === 'win32') {
throw new Error('Daemon installation as Windows service not yet supported. Use "hapi daemon start".');
}
if (process.platform !== 'darwin') {
throw new Error('Daemon installation is currently only supported on macOS');
}
+13 -9
View File
@@ -12,6 +12,7 @@ import packageJson from '../../package.json';
import { getEnvironmentInfo } from '@/ui/doctor';
import { spawnHappyCLI } from '@/utils/spawnHappyCLI';
import { writeDaemonState, DaemonLocallyPersistedState, readDaemonState, acquireDaemonLock, releaseDaemonLock } from '@/persistence';
import { isProcessAlive, isWindows, killProcess, killProcessByChildProcess } from '@/utils/process';
import { cleanupDaemonState, getInstalledCliMtimeMs, isDaemonRunningCurrentlyInstalledHappyVersion, stopDaemon } from './controlClient';
import { startDaemonControlServer } from './controlServer';
@@ -69,6 +70,13 @@ export async function startDaemon(): Promise<void> {
requestShutdown('os-signal');
});
if (isWindows()) {
process.on('SIGBREAK', () => {
logger.debug('[DAEMON RUN] Received SIGBREAK');
requestShutdown('os-signal');
});
}
process.on('uncaughtException', (error) => {
logger.debug('[DAEMON RUN] FATAL: Uncaught exception', error);
logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`);
@@ -362,16 +370,16 @@ export async function startDaemon(): Promise<void> {
if (session.startedBy === 'daemon' && session.childProcess) {
try {
session.childProcess.kill('SIGTERM');
logger.debug(`[DAEMON RUN] Sent SIGTERM to daemon-spawned session ${sessionId}`);
void killProcessByChildProcess(session.childProcess);
logger.debug(`[DAEMON RUN] Requested termination for daemon-spawned session ${sessionId}`);
} catch (error) {
logger.debug(`[DAEMON RUN] Failed to kill session ${sessionId}:`, error);
}
} else {
// For externally started sessions, try to kill by PID
try {
process.kill(pid, 'SIGTERM');
logger.debug(`[DAEMON RUN] Sent SIGTERM to external session PID ${pid}`);
void killProcess(pid);
logger.debug(`[DAEMON RUN] Requested termination for external session PID ${pid}`);
} catch (error) {
logger.debug(`[DAEMON RUN] Failed to kill external session PID ${pid}:`, error);
}
@@ -467,11 +475,7 @@ export async function startDaemon(): Promise<void> {
// Prune stale sessions
for (const [pid, _] of pidToTrackedSession.entries()) {
try {
// Check if process is still alive (signal 0 doesn't kill, just checks)
process.kill(pid, 0);
} catch (error) {
// Process is dead, remove from tracking
if (!isProcessAlive(pid)) {
logger.debug(`[DAEMON RUN] Removing stale session with PID ${pid} (process no longer exists)`);
pidToTrackedSession.delete(pid);
}
+4
View File
@@ -2,6 +2,10 @@ import { logger } from '@/ui/logger';
import { uninstall as uninstallMac } from './mac/uninstall';
export async function uninstall(): Promise<void> {
if (process.platform === 'win32') {
throw new Error('Daemon uninstallation as Windows service not yet supported. Use "hapi daemon start".');
}
if (process.platform !== 'darwin') {
throw new Error('Daemon uninstallation is currently only supported on macOS');
}