GitNexus/gitnexus/test/unit/worker-pool-timeout-retire.test.ts
ChamHerry 681a352006
fix(worker): analyze native worker aborts (#1833)
* fix(analyze): avoid native aborts on generated worker bundles

Retire timed-out parse workers instead of force-terminating native parser state, and skip Monaco generated worker bundles by default while preserving explicit .gitnexusignore negation overrides.

Constraint: Node native tree-sitter bindings can abort the process when a timed-out worker is terminated while inside parser state.

Rejected: Falling back to sequential parsing for native stalls | it can move the same native crash onto the main thread.

Confidence: high

Scope-risk: moderate

Directive: Keep timeout recovery from force-terminating workers until they return to JS or exit naturally.

Tested: npm test; npx tsc --noEmit; npm run build; targeted analyze on /Users/wangxc/Code/keep; gitnexus detect_changes --scope staged

Not-tested: Node 22 LTS runtime and non-macOS platforms

* fix(worker): bound retired parser worker lifetimes

Keep timeout recovery from immediately terminating workers that may still be inside native parser state, while making terminal pool shutdown own retired worker cleanup so long-lived processes do not accumulate retired threads.

Constraint: Claude review on PR #1833 required retiredWorkers cleanup in pool.terminate() and tripBreaker() without regressing no-immediate-terminate timeout safety.

Rejected: clearing the retiredWorkers set without terminating | would remove JS bookkeeping while leaking the underlying worker thread.

Confidence: high

Scope-risk: moderate

Directive: Preserve the distinction between recoverable timeout retirement and terminal pool shutdown; do not reintroduce immediate terminate in removeWorkerFromSlot(..., 'retire').

Tested: npx vitest run test/unit/worker-pool-timeout-retire.test.ts; npx vitest run test/unit/worker-pool-timeout-retire.test.ts test/unit/worker-pool-resilience.test.ts test/unit/worker-pool-cumulative-timeout.test.ts test/unit/worker-pool-slot-generation.test.ts; npx tsc --noEmit; npm run build; npx prettier --check src/core/ingestion/workers/worker-pool.ts test/unit/worker-pool-timeout-retire.test.ts ../docs/todo/pr-1833-retired-worker-cleanup-plan.md; npx eslint src/core/ingestion/workers/worker-pool.ts test/unit/worker-pool-timeout-retire.test.ts; gitnexus detect_changes --scope staged.

Not-tested: npm test full suite did not complete green in this environment; two runs each had one unrelated test/unit/hooks.test.ts parseHookOutput null failure, and each failed hook test passed when rerun in isolation.

* ci: retrigger checks

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: wangxc <wangxc_a_bj@si-tech.com.cn>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Test <test@example.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 17:28:39 +01:00

180 lines
5.9 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { createWorkerPool } from '../../src/core/ingestion/workers/worker-pool.js';
type FirstWorkerBehavior = 'stall' | 'delayed-safe-return';
class TimeoutThenHealthyWorker extends EventEmitter {
static instances: TimeoutThenHealthyWorker[] = [];
static firstWorkerBehavior: FirstWorkerBehavior = 'stall';
static safeReturnDelayMs = 40;
readonly id: number;
terminateCalls = 0;
unrefCalls = 0;
private currentPaths: string[] = [];
constructor() {
super();
this.id = TimeoutThenHealthyWorker.instances.length;
TimeoutThenHealthyWorker.instances.push(this);
queueMicrotask(() => this.emit('message', { type: 'ready' }));
}
postMessage(msg: unknown): void {
if (msg === null || typeof msg !== 'object') return;
const type = (msg as { type?: unknown }).type;
if (type === 'sub-batch') {
const files = (msg as { files?: Array<{ path: string }> }).files ?? [];
this.currentPaths = files.map((file) => file.path);
if (this.id === 0) {
if (TimeoutThenHealthyWorker.firstWorkerBehavior === 'delayed-safe-return') {
setTimeout(() => {
this.emit('message', { type: 'sub-batch-done' });
}, TimeoutThenHealthyWorker.safeReturnDelayMs);
}
return;
}
queueMicrotask(() => {
this.emit('message', { type: 'progress', filesProcessed: this.currentPaths.length });
this.emit('message', { type: 'sub-batch-done' });
});
return;
}
if (type === 'flush') {
const paths = this.currentPaths.slice();
queueMicrotask(() => this.emit('message', { type: 'result', data: { paths } }));
}
}
async terminate(): Promise<number> {
this.terminateCalls++;
this.emit('exit', 0);
return 0;
}
unref(): void {
this.unrefCalls++;
}
}
const waitFor = async (
predicate: () => boolean,
message: string,
timeoutMs = 250,
): Promise<void> => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error(message);
};
let tempDir: string;
let workerUrl: URL;
beforeEach(() => {
TimeoutThenHealthyWorker.instances = [];
TimeoutThenHealthyWorker.firstWorkerBehavior = 'stall';
TimeoutThenHealthyWorker.safeReturnDelayMs = 40;
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-timeout-retire-'));
const workerPath = path.join(tempDir, 'fake-worker.js');
fs.writeFileSync(workerPath, '// fake worker path for createWorkerPool');
workerUrl = pathToFileURL(workerPath) as URL;
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('worker pool timeout retirement', () => {
it('does not immediately terminate a worker that timed out inside native parsing', async () => {
const pool = createWorkerPool(workerUrl, 1, {
subBatchIdleTimeoutMs: 20,
maxTimeoutRetries: 1,
timeoutBackoffFactor: 2,
workerFactory: () =>
new TimeoutThenHealthyWorker() as unknown as import('node:worker_threads').Worker,
});
try {
const results = await pool.dispatch<{ path: string; content: string }, { paths: string[] }>([
{ path: 'src/native-stall.ts', content: 'const x = 1;' },
]);
expect(results).toEqual([{ paths: ['src/native-stall.ts'] }]);
expect(TimeoutThenHealthyWorker.instances.length).toBeGreaterThanOrEqual(2);
expect(TimeoutThenHealthyWorker.instances[0].unrefCalls).toBe(1);
expect(TimeoutThenHealthyWorker.instances[0].terminateCalls).toBe(0);
await pool.terminate();
expect(TimeoutThenHealthyWorker.instances[0].terminateCalls).toBe(1);
} finally {
await pool.terminate();
}
});
it('terminates a retired worker once it returns to a JS-visible safe point', async () => {
TimeoutThenHealthyWorker.firstWorkerBehavior = 'delayed-safe-return';
TimeoutThenHealthyWorker.safeReturnDelayMs = 35;
const pool = createWorkerPool(workerUrl, 1, {
subBatchIdleTimeoutMs: 10,
maxTimeoutRetries: 1,
timeoutBackoffFactor: 2,
workerFactory: () =>
new TimeoutThenHealthyWorker() as unknown as import('node:worker_threads').Worker,
});
try {
const results = await pool.dispatch<{ path: string; content: string }, { paths: string[] }>([
{ path: 'src/native-stall.ts', content: 'const x = 1;' },
]);
expect(results).toEqual([{ paths: ['src/native-stall.ts'] }]);
expect(TimeoutThenHealthyWorker.instances[0].unrefCalls).toBe(1);
await waitFor(
() => TimeoutThenHealthyWorker.instances[0]?.terminateCalls === 1,
'Timed out waiting for retired worker to terminate after safe signal',
);
await pool.terminate();
expect(TimeoutThenHealthyWorker.instances[0].terminateCalls).toBe(1);
} finally {
await pool.terminate();
}
});
it('terminates retired workers when the circuit breaker shuts the pool down', async () => {
const pool = createWorkerPool(workerUrl, 1, {
subBatchIdleTimeoutMs: 10,
maxTimeoutRetries: 1,
timeoutBackoffFactor: 2,
consecutiveFailureThreshold: 1,
workerFactory: () =>
new TimeoutThenHealthyWorker() as unknown as import('node:worker_threads').Worker,
});
try {
await expect(
pool.dispatch<{ path: string; content: string }, { paths: string[] }>([
{ path: 'src/native-stall.ts', content: 'const x = 1;' },
]),
).rejects.toThrow(/circuit breaker/i);
await waitFor(
() => TimeoutThenHealthyWorker.instances[0]?.terminateCalls === 1,
'Timed out waiting for circuit breaker cleanup to terminate retired worker',
);
} finally {
await pool.terminate();
}
});
});