Process Management System
A comprehensive, test-driven process management system for CodeYam that provides:
- Automatic tracking and registration of all spawned processes
- Graceful shutdown with signal escalation (SIGINT → SIGTERM → SIGKILL)
- Parent-child process relationships
- Process metadata and querying capabilities
- Global cleanup on exit/error/signal
Test Coverage
✅ 57 tests passing across 3 test suites:
ProcessManager.test.ts - 32 tests for core functionality
GlobalProcessManager.test.ts - 8 tests for singleton and signal handling
managedExecAsync.test.ts - 17 tests for the managed spawn wrapper
Architecture
Core Components
-
ProcessManager (ProcessManager.ts)
- Central registry for tracking process lifecycle
- Manages state transitions (running → completed/failed/killed)
- Supports parent-child relationships
- Event emitter for process lifecycle events
-
GlobalProcessManager (GlobalProcessManager.ts)
- Singleton wrapper around ProcessManager
- Installs signal handlers for graceful shutdown
- Ensures cleanup on SIGINT, SIGTERM, uncaughtException, unhandledRejection
-
managedExecAsync (managedExecAsync.ts)
- Drop-in replacement for
spawn() with automatic lifecycle management
- Registers processes with the global manager
- Supports all spawn options plus additional metadata
Quick Start
1. Add Signal Handlers to CLI Entry Points
At the top of each CLI entry point (e.g., runLocally.ts, orchestrate.ts):
import { installSignalHandlers } from './lib/process';
installSignalHandlers();
This ensures ALL processes are cleaned up when:
- User presses Ctrl+C (SIGINT)
- Process receives SIGTERM
- Uncaught exception occurs
- Unhandled promise rejection occurs
2. Use managedExecAsync Instead of spawn
Before:
import { spawn } from 'child_process';
const process = spawn('node', ['dist/analyzer/start.js'], {
detached: true,
env: { ...process.env, CODEYAM_PROCESS_NAME: 'analyzer' },
});
After:
import { managedExecAsync, ProcessType } from './lib/process';
const exitCode = await managedExecAsync({
command: 'node',
args: ['dist/analyzer/start.js'],
processType: ProcessType.Analyzer,
processName: 'main-analyzer',
metadata: { projectId: 'abc123' },
});
3. Manual Process Control
For cases where you need more control:
import { getGlobalProcessManager, ProcessType } from './lib/process';
const manager = getGlobalProcessManager();
const allProcesses = manager.listAll();
console.log(`Running ${allProcesses.length} processes`);
const servers = manager.listByType(ProcessType.Server);
const analyzersByName = manager.findByName('main-analyzer');
await manager.shutdown(processId);
await manager.shutdownByType(ProcessType.Capture);
await manager.shutdownAll();
Process Types
enum ProcessType {
Server = 'server',
Analyzer = 'analyzer',
Capture = 'capture',
Controller = 'controller',
Worker = 'worker',
Project = 'project',
Other = 'other',
}
Process States
enum ProcessState {
Running = 'running',
Completed = 'completed',
Failed = 'failed',
Killed = 'killed',
}
Advanced Usage
Parent-Child Relationships
Track hierarchical process relationships:
const { processId: parentId } = managedExecAsync({
command: 'node',
args: ['server.js'],
processType: ProcessType.Server,
processName: 'main-server',
returnProcessId: true,
});
const { processId: childId } = managedExecAsync({
command: 'node',
args: ['worker.js'],
processType: ProcessType.Worker,
processName: 'worker-1',
parentId,
returnProcessId: true,
});
await manager.shutdown(parentId, { shutdownChildren: true });
Process Metadata
Store arbitrary metadata with processes:
await managedExecAsync({
command: 'node',
args: ['analyze.js'],
processType: ProcessType.Analyzer,
processName: 'project-analyzer',
metadata: {
projectId: 'project-123',
commitSha: 'abc123def',
analysisType: 'full',
startedBy: 'user@example.com',
},
});
const analyzers = manager.listByType(ProcessType.Analyzer);
analyzers.forEach((proc) => {
console.log(`Analyzing ${proc.metadata?.projectId}`);
});
Stream Output
Capture stdout/stderr:
const output: string[] = [];
const errors: string[] = [];
await managedExecAsync({
command: 'npm',
args: ['run', 'build'],
processType: ProcessType.Other,
processName: 'build',
onStdout: (data) => output.push(data.toString()),
onStderr: (data) => errors.push(data.toString()),
});
console.log('Build output:', output.join(''));
Abort Signals
Cancel processes programmatically:
const abortController = new AbortController();
const buildPromise = managedExecAsync({
command: 'npm',
args: ['run', 'build'],
processType: ProcessType.Other,
signal: abortController.signal,
});
setTimeout(() => abortController.abort(), 30000);
await buildPromise;
Event Listeners
React to process lifecycle events:
const manager = getGlobalProcessManager();
manager.on('processStarted', (info) => {
console.log(`Started ${info.name} (PID: ${info.pid})`);
});
manager.on('processExited', (info) => {
console.log(`Exited ${info.name} with code ${info.exitCode}`);
});
Integration Examples
Example 1: Update runLocally.ts
import {
installSignalHandlers,
managedExecAsync,
ProcessType,
} from './lib/process';
installSignalHandlers();
async function runLocally(args: RunLocallyArgs) {
const analyzerExitCode = await managedExecAsync({
command: 'node',
args: ['dist/project/start.js', ...buildArgs(args)],
processType: ProcessType.Analyzer,
processName: 'local-analyzer',
metadata: { projectSlug: args.projectSlug },
env: {
...process.env,
PROJECT_SLUG: args.projectSlug,
},
});
if (analyzerExitCode !== 0) {
throw new Error(`Analyzer failed with code ${analyzerExitCode}`);
}
}
Example 2: Update orchestrate.ts
import {
installSignalHandlers,
managedExecAsync,
getGlobalProcessManager,
ProcessType,
} from './lib/process';
installSignalHandlers();
async function runOrchestration(projectSlug: string) {
const manager = getGlobalProcessManager();
const { processId: serverId } = managedExecAsync({
command: 'pnpm',
args: ['runLocally:no-build', projectSlug],
processType: ProcessType.Server,
processName: 'project-server',
metadata: { projectSlug },
returnProcessId: true,
});
const { processId: captureId } = managedExecAsync({
command: 'pnpm',
args: ['captureStatic:no-build', projectSlug],
processType: ProcessType.Capture,
processName: 'screenshot-capture',
parentId: serverId,
metadata: { projectSlug },
returnProcessId: true,
});
const checkProgress = setInterval(() => {
const capture = manager.getInfo(captureId);
if (capture?.state !== 'running') {
clearInterval(checkProgress);
}
}, 1000);
}
Cleanup Behavior
Automatic Cleanup Triggers
The system automatically cleans up ALL managed processes when:
- Normal Exit: Process exits normally
- SIGINT: User presses Ctrl+C
- SIGTERM: System sends termination signal
- Uncaught Exception: Unhandled error occurs
- Unhandled Rejection: Promise rejection not caught
Cleanup Strategy
For each process, the system:
- Sends SIGINT and waits up to 5 seconds
- If still running, sends SIGTERM and waits up to 5 seconds
- If still running, sends SIGKILL and waits up to 2 seconds
- Recursively kills all child processes first (bottom-up)
Manual Cleanup
You can also manually clean up:
const manager = getGlobalProcessManager();
manager.cleanupCompleted({ retentionMs: 60000 });
await manager.shutdownAll();
manager.cleanupCompleted({ retentionMs: 0 });
Migration Guide
Migrating from execAsync
The old execAsync function can be gradually replaced:
import execAsync from './lib/virtualized/common/execAsync';
await execAsync({
command: 'node',
args: ['script.js'],
env: { CODEYAM_PROCESS_NAME: 'my-script' },
});
import { managedExecAsync, ProcessType } from './lib/process';
await managedExecAsync({
command: 'node',
args: ['script.js'],
processType: ProcessType.Other,
processName: 'my-script',
env: { CUSTOM_VAR: 'value' },
});
Migrating Process Tracking
Old manual tracking code:
const processMap: ProcessMap = { server: undefined, capture: undefined };
const serverProcess = spawn('node', ['server.js']);
processMap.server = serverProcess;
if (processMap.server) {
await killProcess(processMap.server.pid);
}
New automatic tracking:
const { processId } = managedExecAsync({
command: 'node',
args: ['server.js'],
processType: ProcessType.Server,
processName: 'server',
returnProcessId: true,
});
const manager = getGlobalProcessManager();
await manager.shutdown(processId);
Testing
Run the test suite:
npx jest background/src/lib/process/__tests__/
npx jest background/src/lib/process/__tests__/ProcessManager.test.ts
npx jest background/src/lib/process/__tests__/ --coverage
Troubleshooting
Processes Not Cleaning Up
If processes aren't being cleaned up:
- Ensure
installSignalHandlers() is called at the top of your CLI entry point
- Check that you're using
managedExecAsync instead of raw spawn()
- Verify the global process manager is being used
Orphaned Processes
If you find orphaned processes:
ps aux | grep CODEYAM
pkill -f CODEYAM_PROCESS_NAME
Test Pollution
If tests are interfering with each other:
afterEach(async () => {
const manager = getGlobalProcessManager();
await manager.shutdownAll();
manager.cleanupCompleted({ retentionMs: 0 });
});
Future Enhancements
Potential improvements:
API Reference
See the inline documentation in:
ProcessManager.ts - Core process management
GlobalProcessManager.ts - Singleton and signal handling
managedExecAsync.ts - Managed spawn wrapper
index.ts - Exported API