From 3b24cf2c9dd0ba38fb2adff0d4df5cd657cf0deb Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 30 Jan 2026 14:03:45 +0100 Subject: [PATCH] feat(01.1-03): add event emission to ProcessManager - ProcessManager accepts optional eventBus parameter - Emit ProcessSpawned event after successful spawn - Emit ProcessStopped event on normal exit (code 0) - Emit ProcessCrashed event on non-zero exit with signal - Add 4 tests verifying event emission behavior - Backwards compatible: events only emitted if eventBus provided --- src/process/manager.test.ts | 103 ++++++++++++++++++++++++++++++++++++ src/process/manager.ts | 51 +++++++++++++++++- 2 files changed, 152 insertions(+), 2 deletions(-) diff --git a/src/process/manager.test.ts b/src/process/manager.test.ts index 12755b5..4784306 100644 --- a/src/process/manager.test.ts +++ b/src/process/manager.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { ProcessRegistry } from './registry.js'; import { ProcessManager } from './manager.js'; +import type { EventBus, ProcessSpawnedEvent, ProcessStoppedEvent, ProcessCrashedEvent } from '../events/index.js'; // Mock execa module vi.mock('execa', () => { @@ -307,4 +308,106 @@ describe('ProcessManager', () => { expect(manager.isRunning('proc-1')).toBe(false); }); }); + + describe('event emission', () => { + let mockEventBus: EventBus; + let managerWithBus: ProcessManager; + + beforeEach(() => { + mockEventBus = { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + }; + managerWithBus = new ProcessManager(registry, mockEventBus); + }); + + it('should emit ProcessSpawned event on spawn', async () => { + await managerWithBus.spawn({ + id: 'proc-1', + command: 'node', + args: ['server.js'], + }); + + expect(mockEventBus.emit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'process:spawned', + timestamp: expect.any(Date), + payload: { + processId: 'proc-1', + pid: 12345, + command: 'node', + }, + }) + ); + }); + + it('should emit ProcessStopped event on normal exit', async () => { + await managerWithBus.spawn({ + id: 'proc-1', + command: 'node', + args: [], + }); + + // Clear spawn event + vi.mocked(mockEventBus.emit).mockClear(); + + // Simulate normal exit (code 0) + exitHandler?.(0, null); + + expect(mockEventBus.emit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'process:stopped', + timestamp: expect.any(Date), + payload: { + processId: 'proc-1', + pid: 12345, + exitCode: 0, + }, + }) + ); + }); + + it('should emit ProcessCrashed event on non-zero exit', async () => { + await managerWithBus.spawn({ + id: 'proc-1', + command: 'node', + args: [], + }); + + // Clear spawn event + vi.mocked(mockEventBus.emit).mockClear(); + + // Simulate crash (non-zero exit with signal) + exitHandler?.(1, 'SIGTERM'); + + expect(mockEventBus.emit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'process:crashed', + timestamp: expect.any(Date), + payload: { + processId: 'proc-1', + pid: 12345, + signal: 'SIGTERM', + }, + }) + ); + }); + + it('should not emit events if eventBus is not provided', async () => { + // Use manager without event bus + await manager.spawn({ + id: 'proc-1', + command: 'node', + args: [], + }); + + // Simulate exit + exitHandler?.(0, null); + + // No errors should occur (events just don't get emitted) + expect(mockEventBus.emit).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/process/manager.ts b/src/process/manager.ts index 647fa7f..71ffb04 100644 --- a/src/process/manager.ts +++ b/src/process/manager.ts @@ -3,11 +3,13 @@ * * Manages spawning, stopping, and lifecycle of child processes. * Uses execa for process spawning with detached mode support. + * Emits domain events via optional EventBus for coordination. */ import { execa, type ResultPromise } from 'execa'; import type { ProcessInfo, SpawnOptions } from './types.js'; import type { ProcessRegistry } from './registry.js'; +import type { EventBus, ProcessSpawnedEvent, ProcessStoppedEvent, ProcessCrashedEvent } from '../events/index.js'; /** Stop timeout in milliseconds before sending SIGKILL */ const STOP_TIMEOUT_MS = 5000; @@ -30,8 +32,12 @@ export class ProcessManager { /** * Create a new ProcessManager. * @param registry - Registry for tracking process metadata + * @param eventBus - Optional event bus for emitting domain events */ - constructor(private registry: ProcessRegistry) {} + constructor( + private registry: ProcessRegistry, + private eventBus?: EventBus + ) {} /** * Spawn a new child process. @@ -78,11 +84,52 @@ export class ProcessManager { // Register in registry this.registry.register(info); - // Set up exit handler to update status + // Emit ProcessSpawned event + if (this.eventBus) { + const event: ProcessSpawnedEvent = { + type: 'process:spawned', + timestamp: new Date(), + payload: { + processId: id, + pid, + command, + }, + }; + this.eventBus.emit(event); + } + + // Set up exit handler to update status and emit events subprocess.on('exit', (code, signal) => { const status = code === 0 ? 'stopped' : 'crashed'; this.registry.updateStatus(id, status); this.handles.delete(id); + + // Emit appropriate event based on exit status + if (this.eventBus) { + if (status === 'stopped') { + const event: ProcessStoppedEvent = { + type: 'process:stopped', + timestamp: new Date(), + payload: { + processId: id, + pid, + exitCode: code, + }, + }; + this.eventBus.emit(event); + } else { + const event: ProcessCrashedEvent = { + type: 'process:crashed', + timestamp: new Date(), + payload: { + processId: id, + pid, + signal, + }, + }; + this.eventBus.emit(event); + } + } }); // Suppress unhandled rejection when process is killed