/** * Tests for WorkerPool (TRUEREF-0022). * * Real node:worker_threads Workers are replaced by FakeWorker (an EventEmitter) * so no subprocess is ever spawned. We maintain our own registry of created * FakeWorker instances so we can inspect postMessage calls and emit events. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { writeFileSync, unlinkSync, existsSync } from 'node:fs'; import { EventEmitter } from 'node:events'; // --------------------------------------------------------------------------- // Hoist FakeWorker + registry so vi.mock can reference them. // --------------------------------------------------------------------------- const { createdWorkers, FakeWorker } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports const { EventEmitter } = require('node:events') as typeof import('node:events'); const createdWorkers: InstanceType[] = []; class FakeWorkerClass extends EventEmitter { threadId = Math.floor(Math.random() * 100_000); // Auto-emit 'exit' with code 0 when a shutdown message is received postMessage = vi.fn((msg: { type: string }) => { if (msg.type === 'shutdown') { // Emit exit asynchronously so shutdown() loop can process it setImmediate(() => { this.emit('exit', 0); this.threadId = 0; // signal exited }); } }); terminate = vi.fn(() => { this.threadId = 0; }); constructor(_script: string, _opts?: unknown) { super(); createdWorkers.push(this); } } return { createdWorkers, FakeWorker: FakeWorkerClass }; }); // --------------------------------------------------------------------------- // Mock node:worker_threads BEFORE importing WorkerPool. // --------------------------------------------------------------------------- vi.mock('node:worker_threads', () => { return { Worker: FakeWorker }; }); import { WorkerPool, type WorkerPoolOptions } from './worker-pool.js'; // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- const FAKE_SCRIPT = '/tmp/fake-worker-pool-test.mjs'; const MISSING_SCRIPT = '/tmp/this-file-does-not-exist-worker-pool.mjs'; function makeOpts(overrides: Partial = {}): WorkerPoolOptions { return { concurrency: 2, workerScript: FAKE_SCRIPT, embedWorkerScript: MISSING_SCRIPT, dbPath: ':memory:', onProgress: vi.fn(), onJobDone: vi.fn(), onJobFailed: vi.fn(), onEmbedDone: vi.fn(), onEmbedFailed: vi.fn(), ...overrides } as unknown as WorkerPoolOptions; } // --------------------------------------------------------------------------- // Setup / teardown // --------------------------------------------------------------------------- beforeEach(() => { // Create the fake script so existsSync returns true writeFileSync(FAKE_SCRIPT, '// placeholder\n'); // Clear registry and reset all mocks createdWorkers.length = 0; vi.clearAllMocks(); }); afterEach(() => { if (existsSync(FAKE_SCRIPT)) unlinkSync(FAKE_SCRIPT); }); // --------------------------------------------------------------------------- // Fallback mode (no real workers) // --------------------------------------------------------------------------- describe('WorkerPool fallback mode', () => { it('enters fallback mode when workerScript does not exist', () => { const pool = new WorkerPool(makeOpts({ workerScript: MISSING_SCRIPT })); expect(pool.isFallbackMode).toBe(true); }); it('does not throw when constructed in fallback mode', () => { expect(() => new WorkerPool(makeOpts({ workerScript: MISSING_SCRIPT }))).not.toThrow(); }); it('enqueue is a no-op in fallback mode — callbacks are never called', () => { const opts = makeOpts({ workerScript: MISSING_SCRIPT }); const pool = new WorkerPool(opts); pool.enqueue('job-1', '/repo/1'); expect(opts.onJobDone).not.toHaveBeenCalled(); expect(opts.onProgress).not.toHaveBeenCalled(); }); it('spawns no workers in fallback mode', () => { new WorkerPool(makeOpts({ workerScript: MISSING_SCRIPT })); expect(createdWorkers).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // Normal mode // --------------------------------------------------------------------------- describe('WorkerPool normal mode', () => { it('isFallbackMode is false when workerScript exists', () => { const pool = new WorkerPool(makeOpts({ concurrency: 1 })); expect(pool.isFallbackMode).toBe(false); }); it('spawns `concurrency` parse workers on construction', () => { new WorkerPool(makeOpts({ concurrency: 3 })); expect(createdWorkers).toHaveLength(3); }); // ------------------------------------------------------------------------- // enqueue dispatches to an idle worker // ------------------------------------------------------------------------- it('enqueue sends { type: "run", jobId } to an idle worker', () => { const pool = new WorkerPool(makeOpts({ concurrency: 1 })); pool.enqueue('job-42', '/repo/1'); expect(createdWorkers).toHaveLength(1); expect(createdWorkers[0].postMessage).toHaveBeenCalledWith({ type: 'run', jobId: 'job-42' }); }); // ------------------------------------------------------------------------- // "done" message — onJobDone called, next queued job dispatched // ------------------------------------------------------------------------- it('calls onJobDone and dispatches the next queued job when a worker emits "done"', () => { const opts = makeOpts({ concurrency: 1 }); const pool = new WorkerPool(opts); // Enqueue two jobs — second must wait because concurrency=1 pool.enqueue('job-A', '/repo/1'); pool.enqueue('job-B', '/repo/2'); const worker = createdWorkers[0]; // Simulate job-A completing worker.emit('message', { type: 'done', jobId: 'job-A' }); expect(opts.onJobDone).toHaveBeenCalledWith('job-A'); // The same worker should now run job-B expect(worker.postMessage).toHaveBeenCalledWith({ type: 'run', jobId: 'job-B' }); }); // ------------------------------------------------------------------------- // "failed" message — onJobFailed called // ------------------------------------------------------------------------- it('calls onJobFailed when a worker emits a "failed" message', () => { const opts = makeOpts({ concurrency: 1 }); const pool = new WorkerPool(opts); pool.enqueue('job-fail', '/repo/1'); const worker = createdWorkers[0]; worker.emit('message', { type: 'failed', jobId: 'job-fail', error: 'parse error' }); expect(opts.onJobFailed).toHaveBeenCalledWith('job-fail', 'parse error'); }); // ------------------------------------------------------------------------- // Per-repo serialization // ------------------------------------------------------------------------- it('does not dispatch a second job for the same repo while first is running', () => { const opts = makeOpts({ concurrency: 2 }); const pool = new WorkerPool(opts); pool.enqueue('job-1', '/repo/same'); pool.enqueue('job-2', '/repo/same'); // Only job-1 should have been dispatched (run message sent) const runCalls = createdWorkers.flatMap((w) => w.postMessage.mock.calls.filter((c) => (c[0] as { type: string })?.type === 'run') ); expect(runCalls.filter((c) => (c[0] as unknown as { jobId: string }).jobId === 'job-1')).toHaveLength(1); expect(runCalls.filter((c) => (c[0] as unknown as { jobId: string }).jobId === 'job-2')).toHaveLength(0); }); it('starts jobs for different repos concurrently', () => { const opts = makeOpts({ concurrency: 2 }); const pool = new WorkerPool(opts); pool.enqueue('job-alpha', '/repo/alpha'); pool.enqueue('job-beta', '/repo/beta'); const runCalls = createdWorkers.flatMap((w) => w.postMessage.mock.calls.filter((c) => (c[0] as { type: string })?.type === 'run') ); const dispatchedIds = runCalls.map((c) => (c[0] as unknown as { jobId: string }).jobId); expect(dispatchedIds).toContain('job-alpha'); expect(dispatchedIds).toContain('job-beta'); }); // ------------------------------------------------------------------------- // Worker crash (exit code != 0) // ------------------------------------------------------------------------- it('calls onJobFailed and spawns a replacement worker when a worker exits with code 1', () => { const opts = makeOpts({ concurrency: 1 }); const pool = new WorkerPool(opts); pool.enqueue('job-crash', '/repo/1'); const originalWorker = createdWorkers[0]; // Simulate crash while the job is running originalWorker.emit('exit', 1); expect(opts.onJobFailed).toHaveBeenCalledWith('job-crash', expect.stringContaining('1')); // A replacement worker must have been spawned expect(createdWorkers.length).toBeGreaterThan(1); }); it('does NOT call onJobFailed when a worker exits cleanly (code 0)', () => { const opts = makeOpts({ concurrency: 1 }); const pool = new WorkerPool(opts); // Exit without any running job const worker = createdWorkers[0]; worker.emit('exit', 0); expect(opts.onJobFailed).not.toHaveBeenCalled(); }); // ------------------------------------------------------------------------- // setMaxConcurrency — scale up // ------------------------------------------------------------------------- it('spawns additional workers when setMaxConcurrency is increased', () => { const pool = new WorkerPool(makeOpts({ concurrency: 1 })); const before = createdWorkers.length; // 1 pool.setMaxConcurrency(3); expect(createdWorkers.length).toBe(before + 2); }); // ------------------------------------------------------------------------- // setMaxConcurrency — scale down // ------------------------------------------------------------------------- it('sends "shutdown" to idle workers when setMaxConcurrency is decreased', () => { const opts = makeOpts({ concurrency: 3 }); const pool = new WorkerPool(opts); pool.setMaxConcurrency(1); const shutdownWorkers = createdWorkers.filter((w) => w.postMessage.mock.calls.some((c) => (c[0] as { type: string })?.type === 'shutdown') ); // Two workers should have received shutdown expect(shutdownWorkers.length).toBeGreaterThanOrEqual(2); }); // ------------------------------------------------------------------------- // shutdown // ------------------------------------------------------------------------- it('sends "shutdown" to all workers on pool.shutdown()', () => { const opts = makeOpts({ concurrency: 2 }); const pool = new WorkerPool(opts); // Don't await — shutdown() is async but the postMessage calls happen synchronously void pool.shutdown(); for (const worker of createdWorkers) { const hasShutdown = worker.postMessage.mock.calls.some( (c) => (c[0] as { type: string })?.type === 'shutdown' ); expect(hasShutdown).toBe(true); } }); // ------------------------------------------------------------------------- // Enqueue after shutdown is a no-op // ------------------------------------------------------------------------- it('ignores enqueue calls after shutdown is initiated', () => { const opts = makeOpts({ concurrency: 1 }); const pool = new WorkerPool(opts); // Don't await — shutdown() sets shuttingDown=true synchronously void pool.shutdown(); // Reset postMessage mocks to isolate post-shutdown calls for (const w of createdWorkers) w.postMessage.mockClear(); pool.enqueue('job-late', '/repo/1'); const runCalls = createdWorkers.flatMap((w) => w.postMessage.mock.calls.filter((c) => (c[0] as { type: string })?.type === 'run') ); expect(runCalls).toHaveLength(0); }); });