Skip to content

fix(proxy): prevent large session requests from cascading into OOM - #1457

Merged
ding113 merged 3 commits into
devfrom
fix/session-message-oom
Aug 27, 2026
Merged

fix(proxy): prevent large session requests from cascading into OOM#1457
ding113 merged 3 commits into
devfrom
fix/session-message-oom

Conversation

@ding113

@ding113 ding113 commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Summary

  • cap legacy streaming hedge concurrency at two in-flight attempts while retaining failure replacement
  • skip request debug artifacts above 5 MiB before structured cloning, while preserving headers and metadata
  • prefer phase snapshots in Session details and only fetch legacy large payloads when required for compatibility

Problem

A roughly 19.36 MB request could fan out across 10-24 legacy hedge attempts. Every attempt cloned, serialized, and transported the full request body, while session debugging persisted several redundant Redis artifacts. Anonymous RSS then reached the 10 GiB pod cgroup limit and the kernel killed Node.

Related Issues:

Fix-chain context: continues the v0.9.4+ OOM hardening series #1439 / #1440 / #1441 / #1453.

Solution

Three complementary bounds, each targeting a different amplifier:

  1. Hedge concurrency cap (forwarder.ts): legacy streaming hedge now launches at most 2 concurrent attempts (LEGACY_STREAMING_HEDGE_MAX_CONCURRENCY). When an in-flight attempt fails, a replacement is still launched, so failover semantics are preserved - only the fan-out width is bounded, capping the per-request memory multiplier on large bodies.
  2. Request artifact size cap (new src/lib/session-request-artifact-limit.ts): SESSION_REQUEST_ARTIFACT_MAX_BYTES (default 5 MiB, range 64 KiB-64 MiB) bounds each request debug artifact (requestBody / messages / before-after snapshot bodies). Oversized artifacts are rejected before structuredClone in the session guard (avoiding the clone/serialize spike entirely) and deleted rather than written in SessionManager (including stale keys from a previous smaller payload). Headers and meta are always preserved so session debugging still works.
  3. Snapshot-first session details (active-sessions.ts): getSessionDetails now derives requestBody/messages/response from the phase snapshots it already loads, and only falls back to the legacy large-payload Redis keys when snapshot content is missing (compatibility with historical data).

Changes

Core Changes

  • src/app/v1/_lib/proxy/forwarder.ts: cap legacy streaming hedge at 2 in-flight attempts, keep failure replacement
  • src/lib/session-request-artifact-limit.ts (new): shared max-bytes helper plus byte-size computation with a buffer-length hint to avoid re-serializing the body just to measure it
  • src/lib/session-manager.ts: canStoreSessionRequestArtifact guard in storeSessionRequestBody / storeSessionMessages / request phase-snapshot writes; oversized values skip the write and delete any stale key
  • src/app/v1/_lib/proxy/session-guard.ts + session.ts: new shouldPersistSessionRequestArtifacts() gate - oversized requests skip structuredClone, getMessages(), and body/messages stores, while the lightweight before-snapshot (headers/meta) is still persisted
  • src/actions/active-sessions.ts: snapshot-first reads with legacy fallback only when snapshot body/messages are absent

Supporting Changes

  • src/lib/config/env.schema.ts + .env.example: new SESSION_REQUEST_ARTIFACT_MAX_BYTES (default 5 MiB, range 64 KiB-64 MiB) with documentation
  • Tests: oversized-artifact skip with headers/meta preservation and stale-key cleanup (session-manager); legacy large-payload getters not called when snapshots exist (active-sessions); guard skips clone/store but keeps the lightweight snapshot; hedge test rewritten to assert the cap plus replacement-after-failure

Behavioral Notes

No migrations or API changes. Two intentional behavior changes reviewers should be aware of:

  • Request debug payloads above 5 MiB are no longer stored in Redis or shown in Session details (headers and metadata remain). Tunable via SESSION_REQUEST_ARTIFACT_MAX_BYTES.
  • Legacy streaming hedge runs at most 2 attempts concurrently instead of expanding across all eligible providers; failed attempts are still replaced.

Testing

Automated Tests

  • Unit tests added/updated across 4 test files covering each bound
  • Focused OOM regression tests: 110 passed
  • bun run typecheck, bun run lint, bun run build
  • Full Vitest: 8,692 passed, 13 skipped; two host-only failures were isolated and passed with LC_ALL=C and a 70-second timeout respectively

Manual Testing

  1. Send a request larger than 5 MiB (e.g., large attachment payload) - verify pod RSS stays bounded, the warn log Skipped oversized session request artifacts fires, and Session details still show headers/meta with bodies omitted.
  2. Trigger legacy streaming hedge with slow/failing providers - verify at most 2 in-flight attempts and that a replacement launches after a failure.

Checklist

  • Code follows project conventions
  • Tests pass locally
  • Documentation updated (.env.example)

Description enhanced by Claude AI

Greptile Summary

This PR limits the memory amplification caused by large session requests while retaining routing recovery and lightweight diagnostics.

  • Caps legacy streaming hedges at two concurrent upstream attempts and launches replacements after failures.
  • Skips oversized request bodies and messages before cloning or Redis persistence while preserving headers and metadata.
  • Uses phase snapshots preferentially for session details and reads legacy payloads only when the corresponding snapshot field is unavailable.
  • Adds configuration documentation and focused regression coverage for artifact limits, snapshot compatibility, and hedge replacement behavior.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete correctness or security failures identified in the changed paths.

The new limits are applied before the principal cloning path and again at Redis write boundaries, hedge failures continue launching replacement providers, and session-detail fallback remains field-specific when snapshots are absent.

Important Files Changed

Filename Overview
src/app/v1/_lib/proxy/forwarder.ts Adds a two-attempt concurrency ceiling to legacy streaming hedges while retaining replacement launches after attempt failures.
src/app/v1/_lib/proxy/session.ts Introduces the request-level persistence gate that avoids expensive diagnostics for oversized inbound bodies.
src/app/v1/_lib/proxy/session-guard.ts Applies the persistence decision before structured cloning and preserves lightweight snapshot headers and metadata when body artifacts are skipped.
src/lib/session-manager.ts Enforces request artifact limits at Redis write boundaries and deletes previously stored oversized values to avoid stale diagnostics.
src/lib/session-request-artifact-limit.ts Centralizes configured request artifact limits and UTF-8 serialized-size measurement.
src/actions/active-sessions.ts Prefers modern phase snapshot fields and conditionally fetches legacy large artifacts only for missing values.
src/lib/config/env.schema.ts Adds validated configuration for the request artifact storage ceiling.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Incoming proxy request] --> B{Request artifact size within limit?}
  B -- Yes --> C[Clone and persist request body/messages]
  B -- No --> D[Persist headers and metadata only]
  C --> E[Forward upstream]
  D --> E
  E --> F[Run at most two legacy hedge attempts]
  F --> G{Attempt succeeds?}
  G -- Yes --> H[Commit winning response]
  G -- No --> I{Another provider available?}
  I -- Yes --> F
  I -- No --> J[Return terminal failure]
  H --> K[Read phase snapshots for session details]
  J --> K
  K --> L{Snapshot field available?}
  L -- Yes --> M[Return snapshot value]
  L -- No --> N[Fetch corresponding legacy artifact]
Loading

Reviews (1): Last reviewed commit: "fix(forwarder): cap legacy streaming hed..." | Re-trigger Greptile

Context used:

Introduce SESSION_REQUEST_ARTIFACT_MAX_BYTES (default 5 MiB, range 64 KiB to 64 MiB) and skip persisting requestBody, messages, and snapshot body or messages whose serialized size exceeds the limit. Lightweight snapshot headers and meta are still written, oversized request payloads are no longer structured-cloned before proxy mutations run, and the SessionManager deletes any pre-existing key when an oversized write is rejected so a previous oversized body cannot linger in Redis.

The guard runs at every relevant SessionManager write site (storeSessionRequestBody, storeSessionMessages, storeSessionRequestPhaseSnapshot) and at ProxySessionGuard, which now consults a new ProxySession.shouldPersistSessionRequestArtifacts predicate before cloning the message and before invoking storeSessionRequestBody or storeSessionMessages.
getSessionDetails used to fetch the legacy requestBody, messages, and response keys on every detail load, even when the modern phase snapshots already contain those fields. Compute candidate body, messages, and response values from the request/response snapshots first and only fall back to the SessionManager.getSessionRequestBody/getSessionMessages/getSessionResponse calls when a snapshot field is missing. The common-path session detail load now avoids three extra large Redis reads, removing a major contributor to memory pressure during dashboard refresh.
The streaming hedge launchAlternative path previously kept spawning new provider attempts as each first-byte deadline expired, so a string of slow or non-responsive upstreams could fan out without bound and accumulate concurrent request bodies in memory. Refuse to launch another attempt when two are already in flight; the scheduler will retry as soon as an in-flight attempt settles. This caps peak concurrency for the legacy hedge schedule while preserving the replacement-on-failure behavior the forwarder relies on.
@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e632a1e9-eb58-46db-83cb-2c986f2a6cfa

📥 Commits

Reviewing files that changed from the base of the PR and between 9e6ab04 and ad00c39.

📒 Files selected for processing (12)
  • .env.example
  • src/actions/active-sessions.ts
  • src/app/v1/_lib/proxy/forwarder.ts
  • src/app/v1/_lib/proxy/session-guard.ts
  • src/app/v1/_lib/proxy/session.ts
  • src/lib/config/env.schema.ts
  • src/lib/session-manager-detail-snapshots.test.ts
  • src/lib/session-manager.ts
  • src/lib/session-request-artifact-limit.ts
  • tests/unit/actions/active-sessions-detail-snapshots.test.ts
  • tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts
  • tests/unit/proxy/session-guard-warmup-intercept.test.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

本次变更为会话请求工件增加可配置的字节上限,限制超限内容写入 Redis,并让详情读取优先使用阶段快照。同时,旧版流式竞速路径将并发 attempt 限制为 2。

Changes

会话请求工件持久化与读取

Layer / File(s) Summary
工件大小限制契约
src/lib/config/env.schema.ts, .env.example, src/lib/session-request-artifact-limit.ts
新增 SESSION_REQUEST_ARTIFACT_MAX_BYTES 配置。工具函数读取最大值并计算 UTF-8 字节数。
工件持久化门控
src/app/v1/_lib/proxy/session.ts, src/app/v1/_lib/proxy/session-guard.ts, src/lib/session-manager.ts, src/lib/session-manager-detail-snapshots.test.ts, tests/unit/proxy/session-guard-warmup-intercept.test.ts
请求体、消息和阶段快照写入现在受大小限制控制。超限时删除对应 Redis key,并保留快照中的 headers 与 meta。
阶段快照优先读取
src/actions/active-sessions.ts, tests/unit/actions/active-sessions-detail-snapshots.test.ts
getSessionDetails 先读取阶段快照。快照缺少字段时,才延迟读取 legacy 工件。

旧版流式竞速并发控制

Layer / File(s) Summary
旧版竞速并发上限
src/app/v1/_lib/proxy/forwarder.ts, tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts
旧版流式竞速最多同时运行 2 个 attempt。首个 attempt 失败后,测试验证系统启动替代 attempt 并记录失败。

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: 🟡 Moderate · up to ad00c

The PR limits hedge concurrency and suppresses oversized debug artifacts, but large request data can still be transformed before the persistence cap, an aborted request can briefly create a replacement upstream attempt, and terminated sessions may retain request artifacts until expiration. These bounded availability and data-lifecycle risks require explicit owner follow-up before merge.

Suggested reviewers: tesgth032, brisbanehuang, apts-1547

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 11 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed 标题准确概括了本次变更的主要目标:防止大型会话请求引发级联 OOM。标题简洁、明确,并与变更内容直接相关。
Description check ✅ Passed 描述清楚说明了并发限制、请求工件大小上限、快照优先读取、根因和验证结果。内容与变更范围直接相关。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 11 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/session-message-oom

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added bug Something isn't working area:core area:session labels Aug 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🧪 测试结果

测试类型 状态
代码质量
单元测试
集成测试
API 测试

总体结果: ✅ 所有测试通过

@github-actions github-actions Bot added the size/M Medium PR (< 500 lines) label Aug 27, 2026
},
};

const snapshotRequestBody = snapshots.request.after?.body ?? snapshots.request.before?.body;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[MEDIUM] [LOGIC-BUG] Legacy requestBody field now prefers the upstream-forwarded body over the client body

Why this is a problem: The legacy key this field mirrors (storeSessionRequestBody / getSessionRequestBody) is documented as "客户端原始请求体" - the client's request body captured at guard time, before any format conversion or request filtering. This derivation prefers snapshots.request.after.body, which is session.forwardedRequestBody: the post-conversion body actually sent to the upstream provider. For cross-format routing (e.g. an OpenAI client served by a Claude provider) or model-redirected requests, data.requestBody returned by GET /api/v1/sessions/{id} now contains a completely different payload than it did before this PR, while both snapshots still exist (the normal case for completed requests). The adjacent snapshotMessages derivation two lines below prefers before first, correctly preserving the client-side semantics of its legacy counterpart - both legacy fields were captured at the same guard-time point, so requestBody should match that ordering.

Suggested fix:

const snapshotRequestBody = snapshots.request.before?.body ?? snapshots.request.after?.body;

The forwarded body remains available to consumers via data.snapshots.request.after, which is what the dashboard UI reads (session-messages-client.tsx only consumes snapshots).

return !this.highConcurrencyModeEnabled;
}

shouldPersistSessionRequestArtifacts(): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[MEDIUM] [TEST-MISSING-CRITICAL] No unit test covers the artifact-size gate's measurement logic

Why this is a problem: This method contains the boundary logic of the OOM fix itself - buffer-hint precedence (avoiding re-serialization of the body), the multipart exception (measuring the small logical body instead of the raw multipart buffer, which would falsely reject image requests), and the byteSize <= maxBytes cutoff. Every test in this PR bypasses the real implementation: session-guard-warmup-intercept.test.ts stubs shouldPersistSessionRequestArtifacts to a constant (lines 97 and 307), and the session-manager tests exercise only the string path of getSessionRequestArtifactByteSize. A regression here - for example dropping the multipart exception so every image request is measured via JSON.stringify, or inverting the hint precedence so the buffer is ignored - would silently re-introduce the serialization spike this PR eliminates, and the suite would still pass. CLAUDE.md requires "All new features must have unit test coverage of at least 80%", and the sibling gate shouldPersistSessionDebugArtifacts is already directly tested in tests/unit/proxy/session.test.ts ("ProxySession high-concurrency policy").

Suggested fix (add to tests/unit/proxy/session.test.ts, reusing its createSession helper):

describe("ProxySession request artifact gate", () => {
  it("buffer 提示超过上限时跳过 request artifacts", () => {
    const session = createSession({ redirectedModel: null, requestMessage: { messages: [] } });
    session.request.buffer = new Uint8Array(6 * 1024 * 1024);
    expect(session.shouldPersistSessionRequestArtifacts()).toBe(false);
  });

  it("multipart 请求按逻辑 body 而非原始 buffer 判定大小", () => {
    const session = createSession({
      redirectedModel: null,
      requestMessage: { model: "gpt-image-1", prompt: "cat" },
    });
    session.request.buffer = new Uint8Array(6 * 1024 * 1024);
    vi.spyOn(session, "isOpenAIImageMultipartRequest").mockReturnValue(true);
    expect(session.shouldPersistSessionRequestArtifacts()).toBe(true);
  });

  it("恰好等于上限时仍保留 artifacts", () => {
    const session = createSession({ redirectedModel: null, requestMessage: { messages: [] } });
    session.request.buffer = new Uint8Array(5 * 1024 * 1024);
    expect(session.shouldPersistSessionRequestArtifacts()).toBe(true);
  });
});

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review Summary

This PR bounds three distinct memory amplifiers (hedge fan-out width, oversized request debug artifacts, redundant Redis reads) with a clean layering: the session guard gates expensive clone/serialize work before it happens, SessionManager re-enforces the limit at every Redis write boundary (including stale-key cleanup), and session details now read legacy large payloads only when snapshots are absent. The hedge cap is correctly placed - failure handling deletes the attempt before re-invoking launchAlternative, so replacement semantics survive while width stays at 2. Two medium issues were found: a semantic drift in the legacy requestBody field, and missing direct test coverage for the new artifact-size gate.

PR Size: M

  • Lines changed: 346 (277 additions, 69 deletions)
  • Files changed: 12

Issues Found

Category Critical High Medium Low
Logic/Bugs 0 0 1 0
Security 0 0 0 0
Error Handling 0 0 0 0
Types 0 0 0 0
Comments/Docs 0 0 0 0
Tests 0 0 1 0
Simplification 0 0 0 0

Critical Issues (Must Fix)

None.

High Priority Issues (Should Fix)

  1. [MEDIUM] Legacy requestBody semantic drift (src/actions/active-sessions.ts:1111, confidence 80): The field now prefers snapshots.request.after.body (the post-conversion body forwarded upstream) over the before-snapshot. The legacy key it replaces was documented as the client's original request body ("客户端原始请求体"), and the adjacent snapshotMessages derivation correctly prefers before first. For cross-format routing, data.requestBody from GET /api/v1/sessions/{id} now returns a different payload than before. Suggested fix: swap the preference to before?.body ?? after?.body.

  2. [MEDIUM] Missing tests for the artifact-size gate (src/app/v1/_lib/proxy/session.ts:588, confidence 80): shouldPersistSessionRequestArtifacts holds the boundary logic of this fix (buffer-hint precedence, multipart exception, size cutoff) but every test in the PR stubs it; a regression would silently re-introduce the serialization spike and still pass CI. Suggested tests provided inline, following the existing shouldPersistSessionDebugArtifacts pattern in tests/unit/proxy/session.test.ts.

Review Coverage

  • Logic and correctness
  • Security (OWASP Top 10)
  • Error handling
  • Type safety
  • Documentation accuracy
  • Test coverage
  • Code clarity

Validated as non-issues during review: hedge cap placement (failure replacement verified against handleAttemptFailure ordering at forwarder.ts:5394-5441), multipart measurement design (logical body excludes primary image file content, so the buffer must not be used as the hint), stale-key deletion paths (all inside logged try/catch), after-snapshot gating (store boundary rejects oversized forwarded bodies while preserving headers/meta), env schema consistency with SESSION_RESPONSE_BODY_MAX_BYTES, and nullish-coalescing correctness in the legacy fallback conditions.


Automated review by Claude AI

@ding113
ding113 merged commit e78f2c4 into dev Aug 27, 2026
16 of 17 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Claude Code Hub Roadmap Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core area:session bug Something isn't working size/M Medium PR (< 500 lines)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant