Skip to content

fix(compression): drain the worker pool queue when spawn throws synchronously - #3

Open
insoln wants to merge 14 commits into
release/v3.8.51from
fix/compression-worker-pool-queue-jam
Open

fix(compression): drain the worker pool queue when spawn throws synchronously#3
insoln wants to merge 14 commits into
release/v3.8.51from
fix/compression-worker-pool-queue-jam

Conversation

@insoln

@insoln insoln commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #2.

In the Turbopack standalone production build, CompressionWorkerPool jams its job queue on the very first job: spawn() throws a synchronous MODULE_NOT_FOUND (Turbopack rewrites new Worker(resolveWorkerFile()) into its module-context map, which only contains .ts keys while the runtime value is a .js path) before the job is dequeued. The rejected caller's promise is silently fail-opened by strategySelector, but the queued PendingJob — retaining the full parsed request body and its closures — stays in pool.queue forever. Under Claude Code traffic this leaked ~2.5–3 GB of main-isolate heap per hour until the heap-pressure guard began shedding with 503s.

Changes

  1. compressionWorkerPool.ts — the queue can no longer jam. dispatch() wraps spawn() in try/catch; on a synchronous throw it fail-opens every queued job (queue.splice(0) → resolve unchanged) and marks the pool broken, so subsequent run() calls resolve immediately instead of re-jamming. This kills the whole leak class regardless of which bundler/module-loader breaks the spawn.
  2. compressionWorkerPool.ts — test seam. The constructor accepts an optional workerFactory; tests inject a synchronously-throwing factory (exactly the production Turbopack failure shape).
  3. failOpenNotifier.ts + callers — fail-open is no longer silent. Spawn failure, broken-pool short-circuit, strategy-level fallback, and LLMLingua spawn/post failures emit a rate-limited console.warn. Repeated identical details are suppressed for 60 s, while a distinct failure mode is still logged immediately; tracked detail keys are bounded.
  4. engines/llmlingua/worker.tsERR_WORKER_PATH. new Worker(pathToFileURL(...).href, …) passed a file:// string, which Node ≥ 21 rejects synchronously. Extracted llmlinguaWorkerSpecifier() returns a URL object.

What this PR deliberately does NOT fix

  • Bundler worker resolution (issue §"Fix worker resolution under Turbopack") and bundling uuid into the esbuild'd worker: those are build-pipeline changes; this PR guarantees they can never again leak memory when they break.
  • The shipped esbuild'd worker's own brokenness (secondary finding in the issue).

Validation

Change type: performance/memory (fail-open semantics unchanged — every failure path still returns { body, compressed: false, stats: null }).

  • TDD: tests/unit/compression/compression-worker-pool-jam.test.ts fails on the untouched release base (jobs hang → timeout) and passes after the fix:
    • every queued job fail-opens when spawn throws synchronously
    • transient spawn failures (EMFILE, ERR_WORKER_INIT_FAILED) are retried on the next wave; structural ones (MODULE_NOT_FOUND etc.) permanently break the pool
    • a populated queue drains when a replacement spawn throws after a worker error
    • postMessage throw, worker error/exit, per-job timeout and close() all settle fail-open — run() never rejects and never strands a promise
    • the fail-open notifier suppresses repeat details but logs a new failure mode immediately
    • broken-pool short-circuits remain observable after the initial spawn failure
    • a broken pool never retries its worker factory
    • the llmlingua worker entry is a URL object, not a file:// string
  • Focused: node --import tsx/esm --test tests/unit/compression/compression-worker-pool-jam.test.ts — 14 passed
  • Adjacent regressions: compression-worker.test.ts, llmlingua-worker.test.ts, llmlingua-worker-resolution.test.ts, llmlingua-failopen.test.ts, compression-worker-file-resolution.test.ts, strategySelector.test.ts — 68 passed
  • node scripts/check/check-mutation-test-coverage.mjs — the new test file is listed in stryker.conf.json tap.testFiles (the remaining drift, quotaScoring.ts, is pre-existing on the release base)
  • npm run typecheck:core
  • Focused Prettier on touched files
  • git diff --check
  • No frozen file-size baselines touched (all four files are unfrozen; check:file-size / check:test-file-size OK)

Tests Added

  • tests/unit/compression/compression-worker-pool-jam.test.ts — the permanent regression guard for the jam/leak class.

…ronously

A synchronous spawn failure (Turbopack standalone moduleContext
MODULE_NOT_FOUND) escaped dispatch() before queue.shift(), stranding
every queued job — each holding a full parsed request body — in
pool.queue for the process lifetime (unbounded main-isolate heap leak,
#2).

- dispatch() now fail-opens the whole queue on a spawn throw and marks
  the pool broken: subsequent run() calls resolve unchanged immediately
  instead of re-jamming
- constructor accepts a workerFactory test seam
- strategySelector fail-open catch now logs (rate-limited): the leak was
  invisible precisely because the catch was silent
- llmlingua worker passes a URL object to new Worker (ERR_WORKER_PATH on
  Node >= 21 for file:// strings)
@insoln
insoln marked this pull request as ready for review September 4, 2026 16:50
…and dark llmlingua catches

- extract notifyCompressionFailOpen into failOpenNotifier.ts (import-cycle-free)
- broken-pool run() now notifies instead of failing open silently forever
- rate limiter keys on detail so distinct new failure modes are never swallowed
- llmlingua pump/postMessage catches log through the notifier
@insoln
insoln changed the base branch from main to release/v3.8.51 September 4, 2026 19:34
- errorText/strategySelector/llmlingua now pass the raw unknown to
  sanitizeErrorMessage (never String(error), which throws on hostile
  toString/valueOf) so run() cannot reject on a non-Error spawn failure
- add an injectable worker harness to the llmlingua backend and cover the
  spawn-throw and postMessage-throw fail-open paths (original text, queue
  continuation, respawn, rate-limited warn)
- add hostile-throwable regression test for the pool

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

CompressionWorkerPool can be marked permanently broken (and drains queued jobs) on a structural spawn failure even when a healthy worker already exists, which can unnecessarily disable compression despite available capacity.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR fixes a production memory leak/jam class in the compression worker path by ensuring CompressionWorkerPool cannot strand queued jobs when worker spawning fails synchronously (e.g., Turbopack standalone MODULE_NOT_FOUND), and adds rate-limited observability for all fail-open paths (including LLMLingua worker failures).

Changes:

  • Make CompressionWorkerPool fail-open and drain queued jobs on synchronous spawn errors, and short-circuit future runs when the pool is broken.
  • Add a shared, rate-limited fail-open notifier used by strategy selection, the pool, and the LLMLingua worker (with sanitization).
  • Fix LLMLingua worker spawning on modern Node by passing a URL object (not a file:// string) to new Worker(...), and add regression tests.
File summaries
File Description
tests/unit/compression/compression-worker-pool-jam.test.ts Adds regression tests covering synchronous spawn throws, queue draining, broken-pool behavior, notifier rate limiting, and LLMLingua worker specifier behavior.
stryker.conf.json Includes the new unit test file in the mutation-test allowlist input set.
open-sse/services/compression/strategySelector.ts Logs (rate-limited) when dynamic import / worker-path execution fails open, rather than silently swallowing.
open-sse/services/compression/failOpenNotifier.ts Introduces the shared rate-limited + sanitized console.warn notifier used across compression worker fail-open paths.
open-sse/services/compression/engines/llmlingua/worker.ts Uses a URL object specifier for Worker entry; adds logging on spawn/postMessage fail-open paths; adds test harness seam.
open-sse/services/compression/compressionWorkerPool.ts Wraps spawn/postMessage failure paths to ensure queue draining and observability; adds test seam for worker construction.
changelog.d/fixes/3-compression-worker-pool-queue-jam.md Documents the fix and related observability/LLMLingua worker specifier adjustment.
Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread open-sse/services/compression/compressionWorkerPool.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The changelog fragment links to a fork (insoln/OmniRoute) instead of the canonical repository link pattern used elsewhere in changelog.d entries.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

changelog.d/fixes/3-compression-worker-pool-queue-jam.md:1

  • The changelog fragment links to the insoln/OmniRoute fork and PR #3, but existing changelog.d entries consistently link to the canonical diegosouzapw/OmniRoute repository. This will produce a broken/misleading reference when merged upstream; please update the link target (and PR/issue number if needed) to match the canonical repo’s URL pattern.
  • Files reviewed: 7/7 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@insoln

insoln commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

Follow-up review addressed in f62ceff: the changelog now references canonical upstream PR diegosouzapw#12755 instead of fork PR #3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants