From 70aef29526a11039ad05238884b27ae04bf57a33 Mon Sep 17 00:00:00 2001 From: NOXX - Commiter Date: Wed, 17 Jun 2026 23:26:20 +0300 Subject: [PATCH] fix(combo): detach per-target listener from shared hedge abort signal (#4116) Integrated into release/v3.8.28 (r8) --- config/quality/file-size-baseline.json | 5 +++-- open-sse/services/combo.ts | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index a8d5f6954a..8627eb3dfd 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -60,7 +60,7 @@ "open-sse/services/batchProcessor.ts": 828, "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, - "open-sse/services/combo.ts": 5289, + "open-sse/services/combo.ts": 5298, "open-sse/services/rateLimitManager.ts": 1017, "open-sse/services/tokenRefresh.ts": 1997, "open-sse/services/usage.ts": 3408, @@ -169,5 +169,6 @@ "_rebaseline_2026_06_16_4001_perplexity_diff_block": "PR #4001 own growth: perplexity-web.ts 939->1013 (+74 = parse the schematized API's RFC-6902 diff_block JSON-patch frames — applyMarkdownDiff + isAnswerTextUsage primary-usage lock + stop only on COMPLETED, not on a still-PENDING final flag — so streamed answers aren't empty, #3938 follow-up). Cohesive single-executor SSE-parsing logic; not separately extractable.", "_rebaseline_2026_06_16_4005_openai_dynamic_models": "PR #4005 own growth: models/route.ts 2494->2512 (+18 = openai model-discovery derives {customBaseUrl}/v1/models from providerSpecificData.baseUrl, SSRF-guarded via safeOutboundFetch+public-only) and pricing.ts 1529->1581 (+52 = pure-data pricing rows closing $0 gaps for registry-exposed ids: openai gpt-5.4/-mini/-nano, gpt-4.1, gpt-4o-2024-11-20, o3 + codex(cx) gpt-5.4-{xhigh,high,medium,low}, gpt-5.3-codex-spark). Cohesive; pricing is data, route change mirrors the anthropic-compat discovery path.", "_rebaseline_2026_06_16_4004_livews_bridge": "PR #4004 own growth: chatCore.ts 5830->5851 (+21 = forwardDashboardEventToLiveWs — a best-effort, non-blocking, timeout-bounded POST that bridges compression.completed events from the main process to the LiveWS sidecar so the dashboard updates under a reverse proxy). Cohesive fire-and-forget beacon at the existing compression emit site; not extractable. Structural shrink of chatCore.ts tracked in #3501.", - "_rebaseline_2026_06_17_4107_pending_reaper": "PR #4107 own growth: usageHistory.ts 854->934 (+80 = orphaned-pending-request reaper — sweepStalePendingRequests() evicts pending details older than 15min + a hard 5000 cap, plus an unref'd 5min sweep timer wired lazily into trackPendingRequest). Fixes an unbounded memory leak where abnormally-terminated requests left payload previews in pendingById forever. Cohesive with the existing pending-request bookkeeping (mirrors the normal removal path: decrement counters + cleanup buckets); not extractable." + "_rebaseline_2026_06_17_4107_pending_reaper": "PR #4107 own growth: usageHistory.ts 854->934 (+80 = orphaned-pending-request reaper — sweepStalePendingRequests() evicts pending details older than 15min + a hard 5000 cap, plus an unref'd 5min sweep timer wired lazily into trackPendingRequest). Fixes an unbounded memory leak where abnormally-terminated requests left payload previews in pendingById forever. Cohesive with the existing pending-request bookkeeping (mirrors the normal removal path: decrement counters + cleanup buckets); not extractable.", + "_rebaseline_2026_06_17_4116_combo_hedge_listener": "combo.ts: +9 lines from #4116 (detach per-target listener from shared hedge abort signal to fix a listener leak). Behavior-preserving cleanup; 5289 -> 5298." } diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 7f482812ff..b74dbbf74e 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -3240,13 +3240,16 @@ export async function handleComboChat({ ...(target ?? {}), modelAbortSignal: timeoutController.signal, }; - if (target?.modelAbortSignal) { - if (target.modelAbortSignal.aborted) { + const parentHedgeSignal = target?.modelAbortSignal ?? null; + let onParentHedgeAbort: (() => void) | null = null; + if (parentHedgeSignal) { + if (parentHedgeSignal.aborted) { timeoutController.abort(new Error("hedge-cancelled")); } else { - target.modelAbortSignal.addEventListener("abort", () => { + onParentHedgeAbort = () => { timeoutController.abort(new Error("hedge-cancelled")); - }); + }; + parentHedgeSignal.addEventListener("abort", onParentHedgeAbort, { once: true }); } } try { @@ -3264,6 +3267,12 @@ export async function handleComboChat({ ]); } finally { clearTimeout(timeoutId); + // Detach our listener from the SHARED parent hedge signal. Without this, every target + // attempt left a listener on the long-lived parent signal for the whole request, so a + // request that tries many combo targets accumulated listeners on one signal. + if (parentHedgeSignal && onParentHedgeAbort) { + parentHedgeSignal.removeEventListener("abort", onParentHedgeAbort); + } } };