Files
OmniRoute/open-sse/services/compression/compressionWorkerPool.ts
anhtahaylove 85a5126dba fix(compression): terminate idle workers on eviction (#13091)
`remove(slot, false)` dropped the slot without `terminate()`, leaving the OS thread and its heap alive — invisible to RSS, which is why 55 orphaned `MessagePort`s took 16 h to surface. Removing the parameter rather than keeping it is the correct call: the pool was its only owner.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:27:45 -03:00

226 lines
7.9 KiB
TypeScript

import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { Worker } from "node:worker_threads";
import type { CompressionResult } from "./types.ts";
import type { StackedCompressionStep } from "./strategySelector.ts";
import type {
CompressionWorkerJob,
CompressionWorkerMessage,
CompressionWorkerOptions,
} from "./compressionWorkerProtocol.ts";
function positiveInteger(value: string | undefined, fallback: number): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
}
/** Relative path (from an install root) to the compression worker. */
const WORKER_JS_REL = join("open-sse", "services", "compression", "compressionWorker.js");
const WORKER_TS_REL = join("open-sse", "services", "compression", "compressionWorker.ts");
const MAX_WALK_UP = 8;
/**
* Walk up from each anchor directory (≤ MAX_WALK_UP levels) and return the first
* ancestor that actually contains `relPath`, or null. Pure + exported for tests.
*
* This deliberately avoids `import.meta.url`/`__dirname` (both dead in the standalone
* bundle) — see the LLMLingua worker comments in llmlingua/worker.ts.
*/
export function firstAncestorWith(anchors: string[], relPath: string): string | null {
for (const anchor of anchors) {
if (!anchor) continue;
let dir = resolve(anchor);
for (let i = 0; i <= MAX_WALK_UP; i++) {
if (existsSync(join(dir, relPath))) return dir;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
}
return null;
}
/**
* Runtime install-root anchors that SURVIVE the standalone bundle:
* - `process.cwd()` — `dist/server.js` runs `process.chdir(__dirname)` → the dist root.
* - `dirname(process.argv[1])` — the entry script (server.js / bin), walked up.
*/
function runtimeAnchors(): string[] {
const anchors = [process.cwd()];
const argv1 = process.argv[1];
if (typeof argv1 === "string" && argv1) anchors.push(dirname(argv1));
return anchors;
}
/**
* Resolve the worker entry file across dev and prod WITHOUT `import.meta.url`.
*
* Prod: the worker is likely a .js file under the install root
* Dev: the same relative path resolves to the `.ts` source under the project
* root (cwd) and runs via the default Node.js loader.
*
* First existing candidate wins. Exported for tests.
*/
export function resolveWorkerFile(): string {
const anchors = runtimeAnchors();
// Prod first: the .js under the install root.
const jsRoot = firstAncestorWith(anchors, WORKER_JS_REL);
if (jsRoot) return join(jsRoot, WORKER_JS_REL);
// Dev: the .ts source.
const tsRoot = firstAncestorWith(anchors, WORKER_TS_REL);
if (tsRoot) return join(tsRoot, WORKER_TS_REL);
// Nothing found — return a cwd-relative .js path; the spawn will fail-open.
return join(process.cwd(), WORKER_JS_REL);
}
function unchanged(body: Record<string, unknown>): CompressionResult {
return { body, compressed: false, stats: null };
}
interface PendingJob extends CompressionWorkerJob {
originalBody: Record<string, unknown>;
resolve: (result: CompressionResult) => void;
onEngineStep?: (step: StackedCompressionStep) => void;
}
interface PoolWorker {
worker: Worker;
job: PendingJob | null;
timeout: NodeJS.Timeout | null;
idle: NodeJS.Timeout | null;
}
export class CompressionWorkerPool {
private readonly queue: PendingJob[] = [];
private readonly workers = new Set<PoolWorker>();
private nextId = 1;
private readonly size: number;
private readonly timeoutMs: number;
private readonly idleMs: number;
constructor({
size = positiveInteger(process.env.OMNI_COMPRESSION_WORKERS, 2),
timeoutMs = positiveInteger(process.env.OMNI_COMPRESSION_WORKER_TIMEOUT_MS, 120_000),
idleMs = positiveInteger(process.env.OMNI_COMPRESSION_WORKER_IDLE_MS, 60_000),
}: { size?: number; timeoutMs?: number; idleMs?: number } = {}) {
this.size = Math.max(1, Math.floor(size));
this.timeoutMs = Math.max(1, Math.floor(timeoutMs));
this.idleMs = Math.max(1, Math.floor(idleMs));
}
run(
body: Record<string, unknown>,
mode: CompressionWorkerJob["mode"],
options?: CompressionWorkerOptions,
onEngineStep?: (step: StackedCompressionStep) => void
): Promise<CompressionResult> {
return new Promise((resolve) => {
this.queue.push({
id: this.nextId++,
body,
mode,
options,
originalBody: body,
resolve,
onEngineStep,
});
this.dispatch();
});
}
async close(): Promise<void> {
for (const job of this.queue.splice(0)) job.resolve(unchanged(job.originalBody));
await Promise.all([...this.workers].map((slot) => this.remove(slot)));
}
private spawn(): PoolWorker {
const slot: PoolWorker = {
worker: new Worker(resolveWorkerFile()),
job: null,
timeout: null,
idle: null,
};
this.workers.add(slot);
slot.worker.on("message", (message: CompressionWorkerMessage) =>
this.handleMessage(slot, message)
);
slot.worker.on("error", () => this.fail(slot));
slot.worker.on("exit", () => {
if (this.workers.has(slot)) this.fail(slot);
});
return slot;
}
private dispatch(): void {
while (this.queue.length) {
let slot = [...this.workers].find((candidate) => !candidate.job);
if (!slot && this.workers.size < this.size) slot = this.spawn();
if (!slot) return;
if (slot.idle) clearTimeout(slot.idle);
const job = this.queue.shift();
if (!job) return;
slot.job = job;
slot.timeout = setTimeout(() => this.fail(slot!), this.timeoutMs);
slot.timeout.unref();
const { originalBody: _body, resolve: _resolve, onEngineStep: _step, ...wireJob } = job;
slot.worker.postMessage(wireJob);
}
}
private handleMessage(slot: PoolWorker, message: CompressionWorkerMessage): void {
const job = slot.job;
if (!job || job.id !== message.id) return;
if (message.type === "step") {
try {
job.onEngineStep?.(message.step);
} catch {
// Telemetry is best-effort.
}
return;
}
this.finish(slot, message.type === "result" ? message.result : unchanged(job.originalBody));
}
private finish(slot: PoolWorker, result: CompressionResult): void {
const job = slot.job;
if (!job) return;
if (slot.timeout) clearTimeout(slot.timeout);
slot.timeout = null;
slot.job = null;
job.resolve(result);
// Idle eviction MUST terminate. Dropping the slot from the set only releases our
// reference - the thread, its MessagePort and its private heap outlive the pool
// for the whole process lifetime, invisible to process.memoryUsage(). (#12812)
slot.idle = setTimeout(() => void this.remove(slot), this.idleMs);
slot.idle.unref();
this.dispatch();
}
private fail(slot: PoolWorker): void {
const job = slot.job;
if (job) job.resolve(unchanged(job.originalBody));
slot.job = null;
void this.remove(slot).finally(() => this.dispatch());
}
/** Drop a slot and release its OS thread. Removal always terminates: a pooled worker
* has no other owner, so skipping terminate() strands the thread permanently. */
private async remove(slot: PoolWorker): Promise<void> {
if (!this.workers.delete(slot)) return;
if (slot.timeout) clearTimeout(slot.timeout);
if (slot.idle) clearTimeout(slot.idle);
await slot.worker.terminate().catch(() => undefined);
}
}
let pool: CompressionWorkerPool | null = null;
export function runCompressionInWorker(
body: Record<string, unknown>,
mode: CompressionWorkerJob["mode"],
options?: CompressionWorkerOptions,
onEngineStep?: (step: StackedCompressionStep) => void
): Promise<CompressionResult> {
pool ??= new CompressionWorkerPool();
return pool.run(body, mode, options, onEngineStep);
}
export async function closeCompressionWorkerPoolForTests(): Promise<void> {
const active = pool;
pool = null;
await active?.close();
}