Files
OmniRoute/open-sse/services/compression/compressionWorkerProtocol.ts
Koosha Paridehpour 7a0b0c64fb fix(compression): track only the recursion path in isStrictlySerializable (#13154) (#13423)
* fix(compression): remove isStrictlySerializable gate that rejects valid bodies (#13154)

Fixes #13154

isCompressionWorkerEligible used isStrictlySerializable to pre-validate
bodies before posting them to a worker thread. The gate is stricter than
structuredClone (which postMessage uses natively), rejecting:
- undefined values (common in optional config fields)
- Date, Map, Set, Uint8Array, RegExp (all structuredClone-compatible)
- Shared (non-cyclic) sub-objects (misread as cycles)

This caused compression to fall back to inline execution on the main
event loop, blocking every concurrent request for the duration of
compression passes — the exact failure mode of #10300.

The serializability walk is a slower, buggier duplicate of the check
postMessage already performs. Removing it:
- Eliminates a recursive walk of the entire body on the main thread
- Fixes false rejections that prevent worker offload
- Allows the catch block at the call site to properly fall through
  to inline compression instead of silently shipping uncompressed

* fix(compression): track only the recursion path, not the whole tree, in isStrictlySerializable (#13154)

Restores the cycle-detection gate instead of removing it: the original
bug was a single `seen` set shared across the entire recursion tree,
never backtracked, so two sibling branches referencing the SAME
non-cyclic sub-object were misread as a cycle. Adding to `seen` before
descending and removing it after (try/finally) fixes the false positive
while a genuine cycle is still rejected before it ever reaches
postMessage/the worker.

Also reverts the worker-failure catch in runCompressionAsync back to
returning the body uncompressed: a worker timeout means the compression
was already too heavy for the worker's own budget, so falling through
to run that same heavy compression synchronously on the main event loop
defeats the point of offloading it to a worker in the first place.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Koosha Pari <koosha@phenotype.ai>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 12:34:09 -03:00

82 lines
2.9 KiB
TypeScript

import type { CompressionConfig, CompressionMode, CompressionResult } from "./types.ts";
import type { StackedCompressionStep } from "./strategySelector.ts";
import type {
CompressionStage,
CompressionWireFormat,
ImageTransportFidelity,
} from "./engines/types.ts";
export interface CompressionWorkerOptions {
model?: string;
supportsVision?: boolean | null;
providerTransport?: "direct" | "aggregator";
provider?: string;
imageTransportFidelity?: ImageTransportFidelity;
sourceFormat?: CompressionWireFormat;
targetFormat?: CompressionWireFormat;
compressionStage?: CompressionStage;
config?: CompressionConfig;
}
export interface CompressionWorkerJob {
id: number;
body: Record<string, unknown>;
mode: CompressionMode;
options?: CompressionWorkerOptions;
}
export type CompressionWorkerMessage =
| { id: number; type: "step"; step: StackedCompressionStep }
| { id: number; type: "result"; result: CompressionResult }
| { id: number; type: "error"; error: string };
function isPlainObject(value: object): value is Record<string, unknown> {
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
// `seen` tracks only the current recursion PATH (ancestors), not every node ever visited:
// add before descending, remove after returning. That way a real cycle (a node reachable
// from itself) is still rejected, but two sibling branches that happen to reference the
// SAME non-cyclic sub-object (a false positive with a globally-shared `seen` set) are not.
export function isStrictlySerializable(value: unknown, seen = new Set<object>()): boolean {
if (
value === null ||
typeof value === "string" ||
typeof value === "boolean" ||
typeof value === "number"
) {
return typeof value !== "number" || Number.isFinite(value);
}
if (typeof value !== "object") return false;
if (seen.has(value)) return false;
seen.add(value);
try {
if (Array.isArray(value)) return value.every((entry) => isStrictlySerializable(entry, seen));
if (!isPlainObject(value)) return false;
return Object.values(value).every((entry) => isStrictlySerializable(entry, seen));
} finally {
seen.delete(value);
}
}
const WORKER_STACK_ENGINES = new Set(["caveman", "rtk", "standard"]);
export function isCompressionWorkerEligible(
body: Record<string, unknown>,
mode: CompressionMode,
options?: CompressionWorkerOptions
): boolean {
if (mode !== "standard" && mode !== "rtk" && mode !== "stacked") return false;
if (mode === "stacked") {
const pipeline = options?.config?.stackedPipeline;
if (!Array.isArray(pipeline) || pipeline.length === 0) return false;
if (
pipeline.some((step) => {
const engine = typeof step === "string" ? step : step.engine;
return !WORKER_STACK_ENGINES.has(engine);
})
) {
return false;
}
}
return isStrictlySerializable({ body, mode, ...(options ? { options } : {}) });
}