GitNexus/gitnexus/test/integration/worker-pool.test.ts
Copilot 16067f882f
fix: prevent premature pool resolution in worker split-and-retry path (#1321)
* Initial plan

* fix: prevent premature pool resolution in worker split-and-retry path

Move `activeWorkers--` from before `await replaceWorker()` to after it.
This prevents `maybeDone()` from seeing `activeWorkers === 0` during the
async gap when another worker finishes and picks up the split jobs.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b65de19d-44ad-4e43-aeb8-4464c8995524

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: revert unrelated package-lock change and improve test comment

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b65de19d-44ad-4e43-aeb8-4464c8995524

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: guard replaceWorker() failure path to prevent pool hang

Wrap `await replaceWorker()` in try/catch so that if worker thread
creation fails, activeWorkers is decremented and fail() is called
rather than leaving the count inflated and the pool hanging.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6bbcf4f4-106d-4120-9a29-e90b9b34640b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address review findings - prettier format, test timer stability, ASCII comments

- Run prettier to fix CI quality/format failure (the try/catch block formatting)
- Increase regression test idle timeout from 150ms to 300ms for CI stability
- Add explicit 15s per-test timeout to prevent hanging on slow runners
- Replace box-drawing U+2500 comment separators with ASCII hyphens

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/66404b55-f6a6-4b0e-9f07-34f0ceaba4be

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* Apply suggestion from @magyargergo

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-04 12:59:35 +01:00

618 lines
22 KiB
TypeScript

/**
* Integration Tests: Worker Pool & Parse Worker
*
* Verifies that the worker pool can spawn real worker threads using the
* compiled dist/ parse-worker.js and process files correctly.
* This is critical for cross-platform CI where vitest runs from src/
* but workers need compiled .js files.
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { createWorkerPool, WorkerPool } from '../../src/core/ingestion/workers/worker-pool.js';
import { pathToFileURL } from 'node:url';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
const DIST_WORKER = path.resolve(
__dirname,
'..',
'..',
'dist',
'core',
'ingestion',
'workers',
'parse-worker.js',
);
const hasDistWorker = fs.existsSync(DIST_WORKER);
function writeTempWorker(prefix: string, source: string): { tempDir: string; workerPath: string } {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
const workerPath = path.join(tempDir, 'worker.js');
fs.writeFileSync(workerPath, source);
return { tempDir, workerPath };
}
describe('worker pool integration', () => {
let pool: WorkerPool | undefined;
afterEach(async () => {
if (pool) {
await pool.terminate();
pool = undefined;
}
});
it.skipIf(!hasDistWorker)('creates a worker pool from dist/ worker', () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
pool = createWorkerPool(workerUrl, 1);
expect(pool.size).toBe(1);
});
it.skipIf(!hasDistWorker)('dispatches an empty batch without error', async () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
pool = createWorkerPool(workerUrl, 1);
const results = await pool.dispatch([]);
expect(results).toEqual([]);
});
it.skipIf(!hasDistWorker)('parses a single TypeScript file through worker', async () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
pool = createWorkerPool(workerUrl, 1);
const fixtureFile = path.resolve(
__dirname,
'..',
'fixtures',
'mini-repo',
'src',
'validator.ts',
);
const content = fs.readFileSync(fixtureFile, 'utf-8');
const results = await pool.dispatch<any, any>([{ path: 'src/validator.ts', content }]);
// Worker returns an array of results (one per worker chunk)
expect(results).toHaveLength(1);
const result = results[0];
expect(result.fileCount).toBe(1);
expect(result.nodes.length).toBeGreaterThan(0);
// Should find the validateInput function
const names = result.nodes.map((n: any) => n.properties.name);
expect(names).toContain('validateInput');
});
it.skipIf(!hasDistWorker)('parses multiple files across workers', async () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
pool = createWorkerPool(workerUrl, 2);
const fixturesDir = path.resolve(__dirname, '..', 'fixtures', 'mini-repo', 'src');
const files = fs
.readdirSync(fixturesDir)
.filter((f) => f.endsWith('.ts'))
.map((f) => ({
path: `src/${f}`,
content: fs.readFileSync(path.join(fixturesDir, f), 'utf-8'),
}));
expect(files.length).toBeGreaterThanOrEqual(4);
const results = await pool.dispatch<any, any>(files);
// Each worker chunk returns a result
expect(results.length).toBeGreaterThan(0);
// Total files parsed should match input
const totalParsed = results.reduce((sum: number, r: any) => sum + r.fileCount, 0);
expect(totalParsed).toBe(files.length);
// Should find symbols from multiple files
const allNames = results.flatMap((r: any) => r.nodes.map((n: any) => n.properties.name));
expect(allNames).toContain('handleRequest');
expect(allNames).toContain('validateInput');
expect(allNames).toContain('saveToDb');
expect(allNames).toContain('formatResponse');
});
it.skipIf(!hasDistWorker)('reports progress during parsing', async () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
pool = createWorkerPool(workerUrl, 1);
const fixturesDir = path.resolve(__dirname, '..', 'fixtures', 'mini-repo', 'src');
const files = fs
.readdirSync(fixturesDir)
.filter((f) => f.endsWith('.ts'))
.map((f) => ({
path: `src/${f}`,
content: fs.readFileSync(path.join(fixturesDir, f), 'utf-8'),
}));
const progressCalls: number[] = [];
await pool.dispatch<any, any>(files, (filesProcessed) => {
progressCalls.push(filesProcessed);
});
// Progress callbacks are best-effort — with a small batch the worker may
// process all files before the progress message is delivered. Just verify
// that if progress was reported, the values are sensible.
if (progressCalls.length > 0) {
expect(progressCalls[progressCalls.length - 1]).toBe(files.length);
}
});
it.skipIf(!hasDistWorker)('terminates cleanly', async () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
pool = createWorkerPool(workerUrl, 2);
await pool.terminate();
pool = undefined; // already terminated
});
it('fails gracefully with invalid worker path', () => {
const badUrl = pathToFileURL('/nonexistent/worker.js') as URL;
// createWorkerPool validates the worker script exists before spawning
expect(() => {
pool = createWorkerPool(badUrl, 1);
}).toThrow(/Worker script not found/);
});
// --- Unhappy paths -----------------------------------------------------
it.skipIf(!hasDistWorker)('dispatch after terminate rejects', async () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
pool = createWorkerPool(workerUrl, 1);
const terminatedPool = pool;
await terminatedPool.terminate();
pool = undefined; // already terminated — prevent afterEach double-terminate
await expect(
terminatedPool.dispatch([{ path: 'x.ts', content: 'const x = 1;' }]),
).rejects.toThrow();
});
it.skipIf(!hasDistWorker)('double terminate does not throw', async () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
pool = createWorkerPool(workerUrl, 1);
await pool.terminate();
await expect(pool.terminate()).resolves.toBeUndefined();
pool = undefined;
});
it.skipIf(!hasDistWorker)(
'dispatches entries with empty content string without crashing',
async () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
pool = createWorkerPool(workerUrl, 1);
const results = await pool.dispatch<any, any>([{ path: 'empty.ts', content: '' }]);
expect(results).toHaveLength(1);
const result = results[0];
expect(typeof result.fileCount).toBe('number');
expect(result.fileCount).toBeGreaterThanOrEqual(0);
expect(Array.isArray(result.nodes)).toBe(true);
},
);
it('treats warning messages as non-terminal and still resolves the worker result', async () => {
const { tempDir, workerPath } = writeTempWorker(
'gitnexus-worker-warning-',
`
const { parentPort } = require('node:worker_threads');
parentPort.on('message', (msg) => {
if (msg && msg.type === 'sub-batch') {
parentPort.postMessage({ type: 'warning', message: 'warning before result' });
parentPort.postMessage({ type: 'sub-batch-done' });
return;
}
if (msg && msg.type === 'flush') {
parentPort.postMessage({ type: 'result', data: { nodes: [], relationships: [], symbols: [], imports: [], calls: [], heritage: [], routes: [], fileCount: 1 } });
}
});
`,
);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const workerUrl = pathToFileURL(workerPath) as URL;
pool = createWorkerPool(workerUrl, 1);
try {
const results = await pool.dispatch<any, any>([
{ path: 'warning.ts', content: 'const x = 1;' },
]);
expect(results).toHaveLength(1);
expect(results[0].fileCount).toBe(1);
expect(warnSpy).toHaveBeenCalledWith('warning before result');
} finally {
warnSpy.mockRestore();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('keeps a slow sub-batch alive when the worker reports progress', async () => {
const { tempDir, workerPath } = writeTempWorker(
'gitnexus-worker-progress-',
`
const { parentPort } = require('node:worker_threads');
parentPort.on('message', (msg) => {
if (msg && msg.type === 'sub-batch') {
let processed = 1;
parentPort.postMessage({ type: 'progress', filesProcessed: processed });
const timer = setInterval(() => {
processed++;
parentPort.postMessage({ type: 'progress', filesProcessed: processed });
if (processed === 4) {
clearInterval(timer);
parentPort.postMessage({ type: 'sub-batch-done' });
}
}, 120);
return;
}
if (msg && msg.type === 'flush') {
parentPort.postMessage({ type: 'result', data: { fileCount: 4 } });
}
});
`,
);
pool = createWorkerPool(pathToFileURL(workerPath) as URL, 1, {
subBatchIdleTimeoutMs: 500,
maxTimeoutRetries: 0,
});
try {
const progressCalls: number[] = [];
const results = await pool.dispatch<any, any>(
Array.from({ length: 4 }, (_, i) => ({ path: `slow-${i}.ts`, content: '' })),
(filesProcessed) => progressCalls.push(filesProcessed),
);
expect(results).toEqual([{ fileCount: 4 }]);
expect(progressCalls).toEqual([1, 2, 3, 4]);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('replaces a timed-out worker and retries with a longer timeout', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-retry-'));
const markerPath = path.join(tempDir, 'first-attempt.txt');
const workerPath = path.join(tempDir, 'worker.js');
fs.writeFileSync(
workerPath,
`
const fs = require('node:fs');
const { parentPort } = require('node:worker_threads');
const markerPath = ${JSON.stringify(markerPath)};
parentPort.on('message', (msg) => {
if (msg && msg.type === 'sub-batch') {
if (!fs.existsSync(markerPath)) {
fs.writeFileSync(markerPath, 'timed out once');
return;
}
parentPort.postMessage({ type: 'sub-batch-done' });
return;
}
if (msg && msg.type === 'flush') {
parentPort.postMessage({ type: 'result', data: { fileCount: 1, recovered: true } });
}
});
`,
);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
pool = createWorkerPool(pathToFileURL(workerPath) as URL, 1, {
subBatchIdleTimeoutMs: 150,
maxTimeoutRetries: 1,
timeoutBackoffFactor: 4,
});
try {
const results = await pool.dispatch<any, any>([{ path: 'retry.ts', content: '' }]);
expect(results).toEqual([{ fileCount: 1, recovered: true }]);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Retrying with 0.6s timeout'));
} finally {
warnSpy.mockRestore();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('preserves global path order across split-and-retry', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-split-'));
const markerPath = path.join(tempDir, 'stalled-once.txt');
const workerPath = path.join(tempDir, 'worker.js');
fs.writeFileSync(
workerPath,
`
const fs = require('node:fs');
const { parentPort } = require('node:worker_threads');
const markerPath = ${JSON.stringify(markerPath)};
let current = [];
parentPort.on('message', (msg) => {
if (msg && msg.type === 'sub-batch') {
current = msg.files.map((file) => file.path);
if (current.includes('stall.ts') && current.length > 1 && !fs.existsSync(markerPath)) {
fs.writeFileSync(markerPath, 'split this job');
return;
}
parentPort.postMessage({ type: 'progress', filesProcessed: current.length });
parentPort.postMessage({ type: 'sub-batch-done' });
return;
}
if (msg && msg.type === 'flush') {
parentPort.postMessage({ type: 'result', data: { fileCount: current.length, paths: current } });
}
});
`,
);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
pool = createWorkerPool(pathToFileURL(workerPath) as URL, 1, {
subBatchSize: 2,
subBatchIdleTimeoutMs: 150,
maxTimeoutRetries: 0,
timeoutBackoffFactor: 3,
});
try {
const progressCalls: number[] = [];
const results = await pool.dispatch<any, any>(
[
{ path: 'first.ts', content: '' },
{ path: 'second.ts', content: '' },
{ path: 'stall.ts', content: '' },
{ path: 'after.ts', content: '' },
],
(filesProcessed) => progressCalls.push(filesProcessed),
);
expect(results.flatMap((result) => result.paths)).toEqual([
'first.ts',
'second.ts',
'stall.ts',
'after.ts',
]);
expect(progressCalls).toEqual([...progressCalls].sort((a, b) => a - b));
expect(progressCalls.at(-1)).toBe(4);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Splitting into 1/1 item jobs'));
} finally {
warnSpy.mockRestore();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('rejects a persistently stalled singleton so the caller can fall back sequentially', async () => {
const { tempDir, workerPath } = writeTempWorker(
'gitnexus-worker-stalled-',
`
const { parentPort } = require('node:worker_threads');
parentPort.on('message', (msg) => {
if (msg && msg.type === 'sub-batch') return;
});
`,
);
pool = createWorkerPool(pathToFileURL(workerPath) as URL, 1, {
subBatchIdleTimeoutMs: 150,
maxTimeoutRetries: 0,
});
try {
await expect(pool.dispatch<any, any>([{ path: 'stalled.ts', content: '' }])).rejects.toThrow(
/sequential fallback/,
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('does not resolve early when a stalled peer job is requeued during another worker finish', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-race-'));
const markerPath = path.join(tempDir, 'stalled-once.txt');
const workerPath = path.join(tempDir, 'worker.js');
fs.writeFileSync(
workerPath,
`
const fs = require('node:fs');
const { parentPort } = require('node:worker_threads');
const markerPath = ${JSON.stringify(markerPath)};
let current = [];
function finish() {
parentPort.postMessage({ type: 'progress', filesProcessed: current.length });
parentPort.postMessage({ type: 'sub-batch-done' });
}
parentPort.on('message', (msg) => {
if (msg && msg.type === 'sub-batch') {
current = msg.files.map((file) => file.path);
if (current.includes('stall-a.ts') && current.length > 1 && !fs.existsSync(markerPath)) {
fs.writeFileSync(markerPath, 'stall the second job once');
return;
}
if (current.includes('tail-a.ts')) {
setTimeout(finish, 180);
return;
}
finish();
return;
}
if (msg && msg.type === 'flush') {
parentPort.postMessage({ type: 'result', data: { fileCount: current.length, paths: current } });
}
});
`,
);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
pool = createWorkerPool(pathToFileURL(workerPath) as URL, 2, {
subBatchSize: 2,
subBatchIdleTimeoutMs: 150,
maxTimeoutRetries: 0,
timeoutBackoffFactor: 3,
});
try {
const results = await pool.dispatch<any, any>([
{ path: 'first-a.ts', content: '' },
{ path: 'first-b.ts', content: '' },
{ path: 'stall-a.ts', content: '' },
{ path: 'stall-b.ts', content: '' },
{ path: 'tail-a.ts', content: '' },
{ path: 'tail-b.ts', content: '' },
]);
expect(results.flatMap((result) => result.paths)).toEqual([
'first-a.ts',
'first-b.ts',
'stall-a.ts',
'stall-b.ts',
'tail-a.ts',
'tail-b.ts',
]);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Splitting into 1/1 item jobs'));
} finally {
warnSpy.mockRestore();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('completes split-and-retry when the timed-out worker is the only active worker', async () => {
// Regression test for: the split-and-retry path resolving early when no other
// workers are active (activeWorkers === 0 during await replaceWorker).
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-sole-active-'));
const markerPath = path.join(tempDir, 'stalled-once.txt');
const workerPath = path.join(tempDir, 'worker.js');
fs.writeFileSync(
workerPath,
`
const fs = require('node:fs');
const { parentPort } = require('node:worker_threads');
const markerPath = ${JSON.stringify(markerPath)};
let current = [];
parentPort.on('message', (msg) => {
if (msg && msg.type === 'sub-batch') {
current = msg.files.map((file) => file.path);
if (current.length > 1 && !fs.existsSync(markerPath)) {
fs.writeFileSync(markerPath, 'stall once');
return;
}
parentPort.postMessage({ type: 'progress', filesProcessed: current.length });
parentPort.postMessage({ type: 'sub-batch-done' });
return;
}
if (msg && msg.type === 'flush') {
parentPort.postMessage({ type: 'result', data: { fileCount: current.length, paths: current } });
}
});
`,
);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
// 2 workers but subBatchSize=4 means all 4 items form 1 job; second worker stays idle.
pool = createWorkerPool(pathToFileURL(workerPath) as URL, 2, {
subBatchSize: 4,
subBatchIdleTimeoutMs: 300,
maxTimeoutRetries: 0,
timeoutBackoffFactor: 3,
});
try {
const results = await pool.dispatch<any, any>([
{ path: 'a.ts', content: '' },
{ path: 'b.ts', content: '' },
{ path: 'c.ts', content: '' },
{ path: 'd.ts', content: '' },
]);
const allPaths = results.flatMap((r: any) => r.paths);
expect(allPaths.sort()).toEqual(['a.ts', 'b.ts', 'c.ts', 'd.ts']);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Splitting into'));
} finally {
warnSpy.mockRestore();
fs.rmSync(tempDir, { recursive: true, force: true });
}
}, 15_000);
it('fails fast on a result message that violates the worker protocol', async () => {
const { tempDir, workerPath } = writeTempWorker(
'gitnexus-worker-protocol-',
`
const { parentPort } = require('node:worker_threads');
parentPort.on('message', (msg) => {
if (msg && msg.type === 'sub-batch') {
parentPort.postMessage({ type: 'result', data: { fileCount: 1 } });
}
});
`,
);
pool = createWorkerPool(pathToFileURL(workerPath) as URL, 1, {
subBatchIdleTimeoutMs: 100,
});
try {
await expect(pool.dispatch<any, any>([{ path: 'bad.ts', content: '' }])).rejects.toThrow(
/protocol error/,
);
await expect(pool.dispatch<any, any>([{ path: 'after.ts', content: '' }])).rejects.toThrow(
/previous failure.*protocol error/,
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('bounds worker jobs by byte budget as well as file count', async () => {
const { tempDir, workerPath } = writeTempWorker(
'gitnexus-worker-byte-budget-',
`
const { parentPort } = require('node:worker_threads');
let current = [];
parentPort.on('message', (msg) => {
if (msg && msg.type === 'sub-batch') {
current = msg.files.map((file) => file.path);
parentPort.postMessage({ type: 'progress', filesProcessed: current.length });
parentPort.postMessage({ type: 'sub-batch-done' });
return;
}
if (msg && msg.type === 'flush') {
parentPort.postMessage({ type: 'result', data: { paths: current } });
}
});
`,
);
pool = createWorkerPool(pathToFileURL(workerPath) as URL, 1, {
subBatchSize: 10,
subBatchMaxBytes: 6,
subBatchIdleTimeoutMs: 100,
});
try {
const results = await pool.dispatch<any, any>([
{ path: 'a.ts', content: '1234' },
{ path: 'b.ts', content: '5678' },
{ path: 'c.ts', content: '90' },
]);
expect(results.map((result) => result.paths)).toEqual([['a.ts'], ['b.ts', 'c.ts']]);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it.skipIf(!hasDistWorker)('createWorkerPool with size 0 creates pool with zero workers', () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
const zeroPool = createWorkerPool(workerUrl, 0);
expect(zeroPool.size).toBe(0);
return zeroPool.terminate();
});
it.skipIf(!hasDistWorker)('dispatch with size 0 rejects clearly', async () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
const zeroPool = createWorkerPool(workerUrl, 0);
try {
await expect(zeroPool.dispatch([{ path: 'x.ts', content: 'const x = 1;' }])).rejects.toThrow(
/no active workers/,
);
} finally {
await zeroPool.terminate();
}
});
});