feat(workers): harden worker lifecycle (messageerror + availableParallelism + ready handshake)

Resolves PR #1693 review H1, H2, M4:

H1 - messageerror handler at every dispatch site
  V8 deserialization failure on postMessage previously left the message
  silently lost; the pool would wait out the idle timeout (default 30s)
  instead of treating it as worker death. The dispatch loop now wires
  worker.once('messageerror', ...) alongside error/exit and routes through
  recoverAndResume so the existing per-slot respawn budget, in-flight
  file attribution, and circuit-breaker layers fire as designed.

H2 - resolveAutoPoolSize uses os.availableParallelism()
  Mirrors the pattern at capabilities.ts:85 (defaultEmbeddingThreads).
  os.cpus().length returns the host CPU count, which over-sizes the pool
  on cgroup-limited containers, taskset-restricted runtimes, and CI
  runners with explicit CPU quotas. Falls back to os.cpus().length on
  Node < 18.14.

M4 - worker-side ready handshake replaces online-trust
  parse-worker.ts now emits {type: 'ready'} after all top-of-script
  initialization completes, BEFORE the message handler is attached. The
  pool's renamed waitForWorkerReady listens for this message under a
  bounded WORKER_READY_TIMEOUT_MS (5s) budget instead of trusting Node's
  online event - which fires when the worker thread starts, BEFORE the
  script body runs, letting init crashes slip past pool startup. ready
  is added to WorkerOutgoingMessage with an exhaustiveness-checked
  no-op branch in the dispatch handler (defensive: the message is
  consumed by waitForWorkerReady before dispatch handlers attach).
  messageerror is wired into waitForWorkerReady the same way.

Test scaffolding:
  - FakeWorker emits {type: 'ready'} in addition to 'online' so
    replacement workers in unit tests don't hit the 5s budget.
  - Integration test ad-hoc worker scripts go through a writeReadyWorker
    helper that prepends the ready handshake. Tests intending to script
    "crash BEFORE ready" can bypass the helper.

61/61 worker-pool unit tests pass; 28/28 integration tests pass.
This commit is contained in:
Gergo Magyar 2026-05-20 08:23:51 +01:00
parent eb31694757
commit 825a6ac21b
4 changed files with 147 additions and 22 deletions

View file

@ -2445,6 +2445,16 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
target.fileCount += src.fileCount;
};
// Signal the pool that worker-side initialization (parser imports, language
// grammars, type-env setup, all helper modules) is complete and the message
// handler below is about to be attached. The pool's `waitForWorkerReady`
// resolves on this handshake — without it, a worker that crashes during
// top-of-script init slips past pool startup (Node's `online` event fires
// before the script body runs) and the pool only notices via the first
// dispatch's idle timeout (~30s). Emit once; the dispatch handler treats
// any subsequent `ready` message as a benign no-op.
parentPort!.postMessage({ type: 'ready' });
parentPort!.on('message', (msg: WorkerIncomingMessage) => {
try {
// Legacy single-message mode (backward compat): array of files

View file

@ -128,7 +128,18 @@ type WorkerOutgoingMessage =
* worker processing defeats). Optional — older worker builds may not
* emit it; pool falls back to the heuristic when absent.
*/
| { type: 'starting-file'; path: string };
| { type: 'starting-file'; path: string }
/**
* Top-of-script ready handshake. Emitted by `parse-worker.ts` AFTER all
* imports + grammar bindings + type-env setup complete, BEFORE the
* message handler is attached. The pool's `waitForWorkerReady` resolves
* on this message — replaces the prior `online`-event-based readiness
* trust, which fired before the script body ran and let init crashes
* slip past pool startup. Once consumed by `waitForWorkerReady`, any
* subsequent `ready` message on the dispatch loop is a no-op (the
* worker only emits it once).
*/
| { type: 'ready' };
interface WorkerJob<TInput> {
startIndex: number;
@ -164,6 +175,17 @@ const DEFAULT_TIMEOUT_BACKOFF_FACTOR = 2;
const DEFAULT_MAX_RESPAWNS_PER_SLOT = 3;
const DEFAULT_MAX_CUMULATIVE_TIMEOUT_FACTOR = 5;
const DEFAULT_CONSECUTIVE_FAILURE_THRESHOLD_FLOOR = 3;
/**
* Bounded wait for a replacement worker to emit the `{type:'ready'}`
* handshake from `parse-worker.ts`. Trusting Node's `online` event alone
* lets a worker that crashes during top-of-script init slip past pool
* startup — the pool only notices on the first dispatch's idle timeout
* (default 30s). 5 seconds is a generous budget for parser + grammar
* imports; if the worker hasn't reported ready by then, it's almost
* certainly stuck or crashed and the pool should surface the failure
* fast rather than wait out the dispatch idle timeout.
*/
const WORKER_READY_TIMEOUT_MS = 5_000;
/**
* Default upper bound on auto-resolved pool size. Past 16 workers the
* dominant cost shifts from worker-side parsing to main-thread merge /
@ -252,20 +274,48 @@ export function resolveWorkerPoolOptions(
export function resolveAutoPoolSize(): number {
const envOverride = nonNegativeInteger(process.env.GITNEXUS_WORKER_POOL_SIZE);
if (envOverride !== undefined) return envOverride;
const cores = os.cpus().length;
// Prefer os.availableParallelism (Node 18.14+) so cgroup CPU limits
// (containers, taskset-restricted runtimes, CI runners with explicit
// CPU quotas) are honored — os.cpus().length returns the host count,
// which over-sizes the pool on constrained shapes and can reintroduce
// the very "main-thread saturated by oversubscription" symptom the
// pool cap exists to prevent. Falls back to os.cpus().length on
// older Node versions. Mirrors `capabilities.ts:85`
// (`defaultEmbeddingThreads`).
const cores =
typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length;
return Math.min(DEFAULT_POOL_SIZE_CAP, Math.max(1, cores - 1));
}
function waitForWorkerOnline(worker: Worker): Promise<void> {
/**
* Wait for a freshly-spawned replacement worker to emit the
* `{type:'ready'}` handshake from `parse-worker.ts` before treating its
* slot as dispatch-ready. Trusting Node's `online` event alone (which
* fires when the worker thread starts, BEFORE the worker script's
* top-of-script body runs) let a worker that crashes during init
* (parser/grammar import failure, missing native binding) slip past
* pool startup. The pool then only noticed the dead replacement on the
* first dispatch's idle timeout (default 30s) — a long stall masking
* an actual crash. This handshake bounds the wait at
* {@link WORKER_READY_TIMEOUT_MS} and surfaces init failures as
* `error` / `exit` / `messageerror` events directly. `messageerror` is
* wired the same way: a V8 deserialization failure during startup is
* treated as worker death and rejects the readiness promise.
*/
function waitForWorkerReady(worker: Worker): Promise<void> {
return new Promise<void>((resolve, reject) => {
const cleanup = () => {
worker.removeListener('online', onOnline);
clearTimeout(timer);
worker.removeListener('message', onMessage);
worker.removeListener('error', onError);
worker.removeListener('exit', onExit);
worker.removeListener('messageerror', onMessageError);
};
const onOnline = () => {
cleanup();
resolve();
const onMessage = (msg: unknown) => {
if (typeof msg === 'object' && msg !== null && (msg as { type?: unknown }).type === 'ready') {
cleanup();
resolve();
}
};
const onError = (err: Error) => {
cleanup();
@ -273,11 +323,29 @@ function waitForWorkerOnline(worker: Worker): Promise<void> {
};
const onExit = (code: number) => {
cleanup();
reject(new Error(`Replacement worker exited with code ${code} before coming online`));
reject(new Error(`Replacement worker exited with code ${code} before reporting ready`));
};
worker.once('online', onOnline);
const onMessageError = (err: Error) => {
cleanup();
reject(
new Error(`Replacement worker emitted messageerror before reporting ready: ${err.message}`),
);
};
// `timer` is declared after `cleanup` so the cleanup closure can reference
// it. The const is reached before any handler attaches below, so no TDZ
// access can fire from the listeners.
const timer = setTimeout(() => {
cleanup();
reject(
new Error(
`Replacement worker did not report ready within ${WORKER_READY_TIMEOUT_MS}ms — likely crashed during top-of-script init`,
),
);
}, WORKER_READY_TIMEOUT_MS);
worker.on('message', onMessage);
worker.once('error', onError);
worker.once('exit', onExit);
worker.once('messageerror', onMessageError);
});
}
@ -488,7 +556,7 @@ export const createWorkerPool = (
if (stopped) return false;
const replacement = spawnWorker(workerUrl);
try {
await waitForWorkerOnline(replacement);
await waitForWorkerReady(replacement);
} catch (err) {
await replacement.terminate().catch(() => undefined);
logger.warn(
@ -879,6 +947,7 @@ export const createWorkerPool = (
worker.removeListener('message', handler);
worker.removeListener('error', errorHandler);
worker.removeListener('exit', exitHandler);
worker.removeListener('messageerror', messageErrorHandler);
};
const finishJob = () => {
@ -1035,6 +1104,12 @@ export const createWorkerPool = (
consecutiveFailuresPerSlot[workerIndex] = 0;
reportProgress();
finishJob();
} else if (msg.type === 'ready') {
// No-op: the ready handshake is consumed by `waitForWorkerReady`
// before dispatch handlers are attached. A stray `ready` here
// (e.g., a future worker build re-emitting after an internal
// recovery) is benign — ignore so the exhaustiveness check
// below keeps catching genuinely-unknown variants.
} else {
// F7: exhaustiveness check — drift-catcher when a future
// WorkerOutgoingMessage variant is added without a handler.
@ -1068,9 +1143,31 @@ export const createWorkerPool = (
}
};
// `messageerror` fires when V8 fails to deserialize a postMessage
// payload (e.g., the worker tries to send a non-cloneable value
// back, or structured-clone hits an unsupported shape). The worker
// stays ALIVE but the message is lost — without this handler the
// pool would sit on the dropped message until the idle timeout
// expires. Treat it as worker death so the resilience layers fire:
// requeue the remainder via `recoverAndResume`, attribute the
// in-flight file from the `starting-file` signal (if observed),
// and let the per-slot respawn budget and circuit breaker decide
// whether to keep this slot in rotation.
const messageErrorHandler = (err: Error) => {
if (!settled) {
settled = true;
cleanup();
void recoverAndResume(
`Worker ${workerIndex} messageerror (postMessage deserialization failure): ${err.message}`,
resolveExcludePaths(),
);
}
};
worker.on('message', handler);
worker.once('error', errorHandler);
worker.once('exit', exitHandler);
worker.once('messageerror', messageErrorHandler);
resetIdleTimer();
if (stopped) {
cleanup();

View file

@ -30,10 +30,22 @@ const DIST_WORKER = path.resolve(
);
const hasDistWorker = fs.existsSync(DIST_WORKER);
// Prepend the M4 ready handshake to every ad-hoc test worker source so the
// pool's `waitForWorkerReady` resolves immediately for replacement spawns.
// Production `parse-worker.ts` emits the same handshake at top-of-script
// before installing its message handler. Without it, every test that triggers
// a replacement (worker crash + recover) would hit the 5s WORKER_READY_TIMEOUT_MS
// and fail with "Replacement worker startup failed and no slots remain".
const READY_PREAMBLE = `require('node:worker_threads').parentPort.postMessage({ type: 'ready' });\n`;
function writeReadyWorker(workerPath: string, source: string): void {
fs.writeFileSync(workerPath, READY_PREAMBLE + source);
}
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);
writeReadyWorker(workerPath, source);
return { tempDir, workerPath };
}
@ -281,7 +293,7 @@ describe('worker pool integration', () => {
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(
writeReadyWorker(
workerPath,
`
const fs = require('node:fs');
@ -327,7 +339,7 @@ describe('worker pool integration', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-replace-fail-'));
const markerPath = path.join(tempDir, 'first-attempt.txt');
const workerPath = path.join(tempDir, 'worker.js');
fs.writeFileSync(
writeReadyWorker(
workerPath,
`
const fs = require('node:fs');
@ -381,7 +393,7 @@ describe('worker pool integration', () => {
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(
writeReadyWorker(
workerPath,
`
const fs = require('node:fs');
@ -479,7 +491,7 @@ describe('worker pool integration', () => {
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(
writeReadyWorker(
workerPath,
`
const fs = require('node:fs');
@ -552,7 +564,7 @@ describe('worker pool integration', () => {
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(
writeReadyWorker(
workerPath,
`
const fs = require('node:fs');
@ -701,7 +713,7 @@ describe('worker pool integration', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-resilience-respawn-'));
const markerPath = path.join(tempDir, 'crashed-once.txt');
const workerPath = path.join(tempDir, 'worker.js');
fs.writeFileSync(
writeReadyWorker(
workerPath,
`
const fs = require('node:fs');
@ -812,7 +824,7 @@ describe('worker pool integration', () => {
const seenPath = path.join(tempDir, 'sub-batches.json');
const markerPath = path.join(tempDir, 'crashed-once.txt');
const workerPath = path.join(tempDir, 'worker.js');
fs.writeFileSync(
writeReadyWorker(
workerPath,
`
const fs = require('node:fs');
@ -887,7 +899,7 @@ describe('worker pool integration', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-resilience-slot-drop-'));
const counterPath = path.join(tempDir, 'crash-count.txt');
const workerPath = path.join(tempDir, 'worker.js');
fs.writeFileSync(
writeReadyWorker(
workerPath,
`
const fs = require('node:fs');
@ -1002,7 +1014,7 @@ describe('worker pool integration', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-resilience-error-event-'));
const markerPath = path.join(tempDir, 'thrown-once.txt');
const workerPath = path.join(tempDir, 'worker.js');
fs.writeFileSync(
writeReadyWorker(
workerPath,
`
const fs = require('node:fs');

View file

@ -33,8 +33,14 @@ class FakeWorker extends EventEmitter {
super();
workerInstances.push(this);
// Real Worker fires 'online' asynchronously after the runtime is ready;
// replicate so `waitForWorkerOnline` resolves.
queueMicrotask(() => this.emit('online'));
// replicate so any code still listening on `online` is satisfied. The
// pool's `waitForWorkerReady` (post-M4) waits for a `{type:'ready'}`
// message instead — emit that too so replacement-worker tests don't
// hit the WORKER_READY_TIMEOUT_MS budget (5s).
queueMicrotask(() => {
this.emit('online');
this.emit('message', { type: 'ready' });
});
}
postMessage(msg: unknown): void {