Skip to content

fix(sse): widen compression worker gate to accept structured-clone-safe values (#13154) - #14391

Merged
diegosouzapw merged 2 commits into
release/v3.8.51from
fix/13154-worker-clone-gate
Sep 24, 2026
Merged

diegosouzapw merged 2 commits into
release/v3.8.51from
fix/13154-worker-clone-gate

Conversation

@diegosouzapw

Copy link
Copy Markdown
Owner

Closes #13154

Root cause

isStrictlySerializable in open-sse/services/compression/compressionWorkerProtocol.ts did
if (typeof value !== "object") return false; before anything else, so any undefined value
anywhere in the tree rejected the whole object — and any non-plain object (Date/Map/Set/
RegExp) failed isPlainObject and was rejected too, even though structuredClone (what
worker.postMessage actually uses) accepts all of these natively.

strategySelector.ts's runCompressionAsync always builds the 9-key workerOptions object
with every key explicitly present (model, supportsVision, providerTransport, provider,
imageTransportFidelity, sourceFormat, targetFormat, compressionStage, config), so
provider: undefined alone (the common case when no provider has been resolved yet) rejected
almost every real stacked/rtk/standard compression call, forcing the heavy caveman/rtk
pipeline onto the main event loop instead of a worker thread.

Fix

  • isStrictlySerializable: accept undefined immediately (it round-trips through
    structuredClone as an absent/undefined key), and accept Date/Map/Set/RegExp (copied
    natively by structuredClone, not walked recursively) before the isPlainObject rejection.
    Functions and Symbols are still rejected; the existing path-based cycle detection (seen
    add/delete, fix(compression): track only the recursion path in isStrictlySerializable (#13154) #13423) is untouched.

  • Companion fix (found while validating this change, same PR): compressionWorker.ts's
    stacked-mode branch called applyStackedCompression directly on the raw job body, skipping
    the adaptBodyForCompression/restore step that the sync in-process path
    (strategySelector.ts's runCompression) always applies for Responses input[] and Kiro
    conversationState envelopes. Because the over-strict gate above meant essentially no real
    stacked call ever reached the worker, this branch was dead code — discovered when the
    existing preserves Responses bodies and hard-budget results test started failing once the
    gate widening actually routed that call to the worker for the first time (worker output
    diverged from the sync path: off-by-one token count, missing hard-budget validation warning).
    Without this companion fix, widening the gate alone would have shipped a live regression
    (miscompressed Responses/Kiro bodies whenever routed to the worker). Fixed by mirroring the
    same adapt/restore wrapping in the worker's stacked branch.

Regression test

New file tests/unit/compression-worker-gate-13154-repro.test.ts (mirrors the plan-file's
proven repro exactly). Before the fix:

✖ EXPECTED (currently RED): the gate should accept that same structured-cloneable shape
  AssertionError: false !== true
✖ EXPECTED (currently RED): realistic runCompressionAsync-shaped call (only `provider` unset) should be eligible
  AssertionError: false !== true
ℹ tests 3
ℹ pass 1
ℹ fail 2

After the fix:

✔ structuredClone accepts a workerOptions shape with an explicit `undefined` key
✔ the gate should accept that same structured-cloneable shape
✔ realistic runCompressionAsync-shaped call (only `provider` unset) should be eligible
ℹ tests 3
ℹ pass 3
ℹ fail 0

Gates run

  • node --import tsx/esm --test tests/unit/compression-worker-gate-13154-repro.test.ts → 3/3 pass
  • node --import tsx/esm --test tests/unit/compression/compression-worker.test.ts → 13/13 pass (full suite, including the companion-fix regression)
  • npm run typecheck:core → exit 0
  • node scripts/check/check-open-sse-typecheck.mjs → openSseTypecheckErrors=0, OK
  • npx eslint --suppressions-location config/quality/eslint-suppressions.json <changed files> → 0 errors
  • node scripts/check/check-file-size.mjs → OK, no frozen file over its baseline
  • node scripts/check/check-changelog-integrity.mjs → OK
  • npx eslint --no-config-lookup --config eslint.complexity.config.mjs (compressionWorker.ts, scoped) → 0 violations
  • npx eslint --no-config-lookup --config eslint.sonarjs.config.mjs (compressionWorker.ts, scoped) → 0 violations
  • Full-repo check-complexity.mjs / check-cognitive-complexity.mjs ratchets (pre-companion-fix baseline) → both OK, within baseline

Existing tests aligned

tests/unit/compression/compression-worker.test.ts:

  • Split rejects functions, symbols, classes, special objects, cycles, and non-finite numbers
    into rejects functions, symbols, cycles, and non-finite numbers (kept) plus two new tests
    asserting Date/Map/Set/RegExp and undefined are now accepted — this is alignment to
    the corrected contract (these values are structured-clone-safe), not a weakening: the cycle,
    function, Symbol and non-finite-number rejections are all still asserted.
  • Added a new regression test mirroring strategySelector.ts's exact 9-key workerOptions
    shape with provider unset, asserting isCompressionWorkerEligible(...) === true.
  • preserves Responses bodies and hard-budget results needed no edit — it started passing
    again once the companion fix above was added (see Root cause / Fix for the investigation).

Plan-file: _tasks/pipeline/bugs/2-implementing/13154-fix-backend-compression-worker-gate-rejects-structured-cloneab.plan.md

…fe values (#13154)

isStrictlySerializable rejected any undefined value and any non-plain object
(Date/Map/Set/RegExp) before ever checking whether structuredClone (what
worker.postMessage actually uses) would accept it. strategySelector.ts's
runCompressionAsync always builds the 9-key workerOptions object with every
key explicitly present, so provider: undefined alone (the common case when
no provider is resolved yet) rejected almost every real stacked/rtk/standard
compression call, forcing it onto the main event loop instead of a worker
thread.

Widen isStrictlySerializable to accept undefined and the structured-clone-
native Date/Map/Set/RegExp types (copied, not walked, by structuredClone),
while still rejecting functions and Symbols.

Companion fix: compressionWorker.ts's stacked-mode branch called
applyStackedCompression directly on the raw job body, skipping the
adaptBodyForCompression/restore step that the sync in-process path
(strategySelector.ts's runCompression) always applies for Responses
input[] and Kiro conversationState envelopes. Because the over-strict gate
above meant essentially no real stacked call ever reached the worker, this
was dead code until this fix; without also fixing it, widening the gate
would have shipped a live regression that miscompresses Responses/Kiro
bodies whenever they are now correctly routed to the worker.
@diegosouzapw
diegosouzapw merged commit 8909f82 into release/v3.8.51 Sep 24, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(backend): compression worker gate rejects structured-cloneable bodies, forcing engines onto the event loop

1 participant