GitNexus/gitnexus/test/unit/worker-pool-error-stack.test.ts

176 lines
6.2 KiB
TypeScript

import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'node:events';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import fs from 'node:fs';
import os from 'node:os';
import {
createWorkerPool,
WorkerPoolDispatchError,
} from '../../src/core/ingestion/workers/worker-pool.js';
/**
* #2068 regression: a worker-side crash must carry its stack across the
* MessageChannel so the surfaced "Phase 'parse' failed" error points at a real
* frame instead of a bare one-liner (the issue's `this.#q is not a function`
* reached the operator with no file:line because the worker only sent
* `err.message`). These tests assert the worker stack rides through both worker
* failure channels — the `{type:'error'}` message (a caught worker throw) and
* the Node `'error'` event (an uncaught worker throw) — into the
* `WorkerPoolDispatchError` the parse phase rejects with, and that an older
* worker build that omits the stack still degrades cleanly.
*/
type NodeWorker = import('node:worker_threads').Worker;
type FakeAction =
| { kind: 'error-message'; error: string; errorStack?: string }
| { kind: 'error-event'; message: string; stack: string };
const nextActions: FakeAction[] = [];
/**
* Minimal worker double: emits the readiness handshake on construction, then
* runs one scripted action per dispatched sub-batch. Unlike the resilience
* suite's double, this one can emit the `{type:'error', errorStack}` MESSAGE
* (the worker's own caught-error path) in addition to the Node `'error'` event.
*/
class FakeWorker extends EventEmitter {
constructor() {
super();
queueMicrotask(() => {
this.emit('online');
this.emit('message', { type: 'ready' });
});
}
postMessage(rawMsg: unknown): void {
const m = rawMsg as { type?: string };
// Only a real dispatch drives an action; ignore the pool's `flush` reply.
if (!m || m.type !== 'sub-batch') return;
const action = nextActions.shift();
if (!action) return;
queueMicrotask(() => {
if (action.kind === 'error-message') {
this.emit('message', {
type: 'error',
error: action.error,
errorStack: action.errorStack,
});
} else {
const e = new Error(action.message);
e.stack = action.stack;
this.emit('error', e);
}
});
}
async terminate(): Promise<number> {
this.emit('exit', 0);
return 0;
}
}
let tempDir: string;
let workerUrl: URL;
beforeEach(() => {
nextActions.length = 0;
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-error-stack-'));
const workerPath = path.join(tempDir, 'fake-worker.js');
fs.writeFileSync(workerPath, '// fake');
workerUrl = pathToFileURL(workerPath) as URL;
});
afterEach(() => {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// best-effort cleanup
}
});
// Trip the breaker on the very first death so the reason string is surfaced
// verbatim in the rejected WorkerPoolDispatchError.
const TRIP_ON_FIRST_DEATH = {
consecutiveFailureThreshold: 1,
maxRespawnsPerSlot: 0,
} as const;
async function dispatchAndCatch(pool: ReturnType<typeof createWorkerPool>): Promise<unknown> {
try {
await pool.dispatch<{ path: string; content: string }, unknown>([
{ path: 'src/a.ts', content: '' },
]);
return undefined;
} catch (e) {
return e;
}
}
describe('worker-pool error stack propagation (#2068)', () => {
it('embeds the worker stack from a {type:error} message into the surfaced error', async () => {
const workerStack =
'TypeError: this.#q is not a function\n' +
' at frobnicate (/dist/core/ingestion/workers/parse-worker.js:1234:56)';
const pool = createWorkerPool(workerUrl, 1, {
workerFactory: () => new FakeWorker() as unknown as NodeWorker,
...TRIP_ON_FIRST_DEATH,
});
nextActions.push({
kind: 'error-message',
error: 'this.#q is not a function',
errorStack: workerStack,
});
const caught = await dispatchAndCatch(pool);
expect(caught).toBeInstanceOf(WorkerPoolDispatchError);
const msg = (caught as Error).message;
expect(msg).toContain('this.#q is not a function');
expect(msg).toContain('worker stack:');
expect(msg).toContain('frobnicate (/dist/core/ingestion/workers/parse-worker.js:1234:56)');
await pool.terminate();
});
// The Node 'error' event fires on an UNCAUGHT JS throw / async rejection (which
// carries a real JS stack). A true NATIVE abort (tree-sitter SIGSEGV / OOM kill)
// instead fires the 'exit' event and is intentionally stackless — no JS frame
// exists — so it is NOT exercised here.
it('embeds the worker stack from a Node error event (uncaught throw) into the surfaced error', async () => {
const workerStack =
'Error: uncaught worker throw\n' +
' at processFileGroup (/dist/core/ingestion/workers/parse-worker.js:777:9)';
const pool = createWorkerPool(workerUrl, 1, {
workerFactory: () => new FakeWorker() as unknown as NodeWorker,
...TRIP_ON_FIRST_DEATH,
});
nextActions.push({ kind: 'error-event', message: 'uncaught worker throw', stack: workerStack });
const caught = await dispatchAndCatch(pool);
expect(caught).toBeInstanceOf(WorkerPoolDispatchError);
const msg = (caught as Error).message;
expect(msg).toContain('worker stack:');
expect(msg).toContain('processFileGroup (/dist/core/ingestion/workers/parse-worker.js:777:9)');
await pool.terminate();
});
it('degrades to message-only when an older worker build omits errorStack', async () => {
const pool = createWorkerPool(workerUrl, 1, {
workerFactory: () => new FakeWorker() as unknown as NodeWorker,
...TRIP_ON_FIRST_DEATH,
});
// No errorStack — the wire field is optional for back/forward compat.
nextActions.push({ kind: 'error-message', error: 'legacy worker failure' });
const caught = await dispatchAndCatch(pool);
expect(caught).toBeInstanceOf(WorkerPoolDispatchError);
const msg = (caught as Error).message;
expect(msg).toContain('legacy worker failure');
expect(msg).not.toContain('worker stack:');
await pool.terminate();
});
});