Skip to content

[PR] fix: release request input buffers before long-running responses - #4766

Open
ranxianglei wants to merge 7 commits into
masterfrom
2026-10-26_input-buffer-lifetime
Open

ranxianglei wants to merge 7 commits into
masterfrom
2026-10-26_input-buffer-lifetime

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

Closes #4753

What

Three input-lifetime retention sites kept request-side memory alive across the entire response of large requests:

  1. readBody (src/server.ts) never removed its data/end/error listeners, so a closure holding every network chunk stayed attached to the request for its whole life; a close before end left the promise pending forever (admission-slot leak). Now every settlement path (success / error / abort / oversize) detaches its own listeners and drops the chunk array, and close-before-end rejects with an explicit abort error.

  2. rememberPluginMessages (src/plugin.ts) received the raw inbound Buffer after forwarding and re-parsed + re-walked it for the text-comparability verdict. The raw-wire hash and verdict are now computed before prepare mutates the parsed graph (anchored on the original parse, gated on the plugin lane), and only those two scalars cross to the existing post-forward bookkeeping point. Hash-gate semantics unchanged.

  3. handleRequest's frame (src/server/handle.ts) retained the raw body, decoded text, parsed graph and the overflow-refold preparation closure across the entire response stream. forward() now takes an onRetriesSettled callback fired exactly once when every upstream attempt has finished (initial send, role ladder, pck probe, image-count retry, overflow refold), before the first response byte; handleRequest releases its input cells there.

Per the issue scope: accepted outbound bodies (fake-completion/blind retries, echo provenance) and the session pin stay through the response. No admission controls, no storage changes, no kernel changes, no config surface added.

Behavior change (disclosure)

  • Request-body close-before-end: previously left a permanently pending readBody promise and leaked the admission slot until process exit; now rejects with "request aborted before body completed". The admission slot is released either way.
  • No other wire/config/persistence change. publicSnapshotWireHash / publicSnapshotTextComparable values are byte-identical to before (pinned by a parity corpus against the old algorithm plus e2e pipeline tests asserting the exact inbound hash).

Validation

  • New tests/issue4753-input-lifetime.test.ts: precomputed-vs-old-algorithm parity corpus (13 wire shapes incl. garbage/null/array/unicode), scalar-gate unit tests, e2e pipeline parity through the real proxy (exact sha256 of the sent bytes + both verdict directions), settlement-ordering probes (fires exactly once, before the first streamed byte on 200 and before the error write on 400, never on fast paths), same-session concurrency smoke. Plus four listener-removal regressions in tests/proxy-readbody.test.ts covering all settlement paths (the five-byte repro from the issue).
  • Regression gates run locally green: refold family (issue1195-overflow-refold, e2e-overflow-selfheal, issue3787-overflow-refold, fix-arm-headroom-clamp, refold-wiring), plugin-fork, session-transient-memory, context-overflow; typecheck + build clean. Full-suite verdict is the remote CI test lanes on this head.
  • Branch carries a merge of origin/ci-fixes (sentinel lane, CI Control Center: Centralized CI Failure Handling & Status Sync (Comment all CI issues here; do not open new issues) | CI 总控: CI 失败集中处理与状态同步(所有 CI 问题在此评论, 勿开新 issue) #4599) to ride out inherited master reds.

ci-check added 2 commits October 26, 2026 02:18
Three retention sites kept input-only data alive across the whole
response stream of large requests:

1. readBody never removed its data/end/error listeners, so the closure
   holding every network chunk stayed attached to the request for its
   whole life; a close before end left the promise dangling forever
   (admission leak). All four settlement paths now detach their own
   listeners and drop the chunk array; close-before-end rejects with an
   explicit abort error instead of hanging.

2. rememberPluginMessages took the raw inbound Buffer AFTER forwarding
   and re-parsed + re-walked it. The raw-wire hash and text-comparability
   verdict are now computed BEFORE prepare mutates the parsed graph
   (anchored on the original parse, gated on the plugin lane) and only
   those two scalars cross to the existing post-forward bookkeeping
   point; hash-gate semantics are unchanged.

3. handleRequest's frame retained the raw body, decoded text, parsed
   graph and the overflow-refold preparation closure across the entire
   response stream. forward() now takes an onRetriesSettled callback,
   fired exactly once when every upstream attempt has finished (initial
   send, role ladder, pck probe, image-count retry, overflow refold),
   before the first response byte; handleRequest releases its input
   cells there. Accepted outbound bodies (fake-completion/blind retries,
   echo provenance) and the session pin stay through the response by
   design.

Behavior change: a client disconnect mid-body now surfaces as a
rejected readBody ("request aborted before body completed") instead of
a permanently pending promise; the admission slot is released either
way. Previously the slot leaked until process exit.

Closes #4753
@ranxianglei ranxianglei changed the title fix: release request input buffers before long-running responses [PR] fix: release request input buffers before long-running responses Oct 25, 2026
…nput (#4753)

Review finding on this PR: affinityMessagesCache (chain-fingerprint /
resume-detection memo) aliases parsed.messages or input/contents, so the
inbound message array stayed pinned on the suspended handleRequest frame
across the whole response stream even after parsed was released. Null it in
releaseRequestInput alongside the other input cells.

Verified: typecheck clean, tests/issue4753-input-lifetime.test.ts +
tests/proxy-readbody.test.ts 30/30 green.
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

(ai: vllm-qwen/qwen3.8-27b)

查重: 重复 #4756(同症状 + 同机制 + 同根因,先来后到 #4756 优先)。

标签: pr(原有)、duplicate(与 #4756 同机制并行)、bug(内存驻留缺陷)、scope/wiring(请求管道输入生命周期)、p2(功能可用,大请求长响应下慢性内存增长,有 OOM 风险但不破坏功能)。

独立审核(未复用 #4753/#4756 任何一侧的既有结论,diff 两边都重读了)

本 PR 实现正确,逐项核实过:

  1. readBody:成功/错误/abort/oversize 四条结算路径全部 detach 四个监听器并清空 chunk 数组;oversize 先 settle 再 destroy 再 reject,无双结算;close-before-end 从「promise 永久挂起 + admission slot 泄漏」变为 reject request aborted before body completed。所有调用方(handle.ts divert 路径、主管道 catch、admin 面板、incremental-lane、plugin-render)逐个查过,无新增悬挂或吞错。这是唯一语义变更,披露合理(旧值=永久 pending 泄漏 → 新值=显式 reject,slot 两条路都释放)。
  2. hash/verdict 前移:锚定原始 parse 结果、只有两个标量跨界;session.pluginSnapshot 的所有写入点本来就都在 pluginAgent === "string" 门内(fork 子拷贝、存储恢复路径同门),唯一行为消费方 forkSnapshot() 对非 plugin 会话先走 raw snapshot unavailable guard——「只在 plugin 车道计算」相对旧「全模式写元数据」不构成回归,旧写入是无行为消费者的死数据。
  3. onRetriesSettled:fire 点确认在所有上游尝试(initial send / role ladder / pck probe / image-count retry / overflow refold)结束之后、消费上游响应(首字节)之前;attempt 与 settlement 之间无 early return;fast path(codexForge/autoModeForge/本地拒绝)不 fire,但那些路径本就快速终结;恰好一次。settlement 之后 handleRequest 无任何输入侧读者(INCOMING dump 与 publicForkInputCheck 均在 forward 前同步执行)。
  4. 测试是真测试:parity corpus 用旧算法逐字做 oracle(12 种 wire 形状含 garbage/null/array/unicode/thinking block);e2e 走真实 proxy 断言入站字节精确 sha256 + verdict 双向;ordering probe monkeypatch res.write/end 断言 settled 先于首字节;4 条监听器回归逐路径断言 listenerCount===0。非恒真断言,bug 回归时抓得住。

本地(head 1544b5a):typecheck/build clean;issue4753-input-lifetime + proxy-readbody 30/30;refold 族 + plugin-fork + session-transient-memory + context-overflow 共 120/120;e2e-overflow-selfheal 3/3、fix-arm-headroom-clamp 3/3。

审核中发现一个真实缺口(已修,已推):affinityMessagesCache(src/server/handle.ts:1248,chain 指纹/resume 检测 memo,别名 parsed.messages/input/contents)在每条 identified-client 请求上被填充(chainFingerprint @1466),但 releaseRequestInput 没有置空它——挂起帧在整个响应流期间继续持有入站消息数组,而大长会话请求里 messages 是 body 的大头,最大的一块没放掉。无正确性影响(settlement 后无人读),但 #4753 的内存目标没完全达成。#4756 放了这一处。已在本分支直接修复(releaseRequestInput 增加 affinityMessagesCache = null;,独立 commit,标注 #4753),typecheck clean + 30/30 绿,新 head 139c9db。注:该 head 之前的 CI runs 全部被我这次 push 顶替取消,新 run 在途;分支已含 ci-fixes 1fa9373(当前 tip,#4599 floor 205),预期继承红不复发,权威判定仍以远端 CI 为准。

与 #4756 的对比(同机制家族):

八问

  1. 是什么问题:大请求在处理期间把请求侧内存(body chunk、解析图、消息数组)一直留到整个响应流结束,大请求负载下堆持续增长(fix: release request input buffers before long-running responses #4753 观察到 4 GiB OOM;报告本身说明该 OOM 的确切分配点未钉死,但这类保留是确定的不必要持有)。
  2. 什么原因导致的:三个独立保留机制——① readBody 监听器永久挂载不 detach(close-before-end 时 promise 永久挂起连带 slot 泄漏);② rememberPluginMessages 转发后拿原始 Buffer 重解析重遍历(冗余工作且延长 Buffer 寿命);③ handleRequest 是挂起的 async 帧,V8 保留挂起帧的全部局部变量,输入侧 locals(含 memo 缓存)把分配钉在整个响应流上。
  3. 影响范围与严重程度:任何模式 × 大请求(长会话压缩回放 body 可达 MB 级)× 长响应(流式生成),响应越长驻留越久;严重度 p2——功能无损、可绕行(重启进程),但车队规模大请求负载下是慢性内存增长/OOM 风险。
  4. 怎么修复的:「重试结算即释放输入单元」——readBody 每条结算路径 detach 监听器并清空 chunk;hash/verdict 前移到 prepare 变更之前只带两个标量;forward() 增加 onRetriesSettled 回调,在所有上游尝试结束、首字节之前恰好 fire 一次,handleRequest 在那里置空 bodyBuffer/bodyText/parsed/overflowRefold/(affinityMessagesCache,本次审核补修)。出站 body 与 session pin 按设计保留(fake-completion/blind retry 与 echo provenance 结算后还要读)。
  5. 是否有回归问题:审过的既有行为——(a) close-before-end:旧=永久 pending+slot 泄漏 → 新=显式 reject,调用方全部安全处理,唯一语义变更且已披露;(b) publicSnapshotWireHash/TextComparable 值字节不变(parity corpus + e2e 精确 sha256 钉住),写入面收窄到 plugin 车道经核实无行为损失;(c) refold/role ladder/pck/image-count 重试语义不变(fire 点在尝试结束后、响应消费前,恰好一次,fast path 不受影响);(d) admission slot 生命周期不变。未发现其他语义漂移。
  6. 是否破坏缓存:不涉及——出站字节不变(hash-gate 语义保持,parity corpus 钉住),前缀缓存接缝不受影响;释放的纯是请求侧内部内存。
  7. 是否改了设计方向:不涉及——架构/边界/数据流无转向,现有管道内收紧输入生命周期;未加配置面。
  8. 是否引入新配置:不涉及——无新 env/config/默认值。

收工判定

代码质量通过(缺口已补)。建议合 #4756(先来后到 + 覆盖更完整),前置条件:把它 merge 一次 origin/ci-fixes 清掉 5 片继承红(哨兵批量或作者自行均可);本 PR 已打 duplicate,等 owner 决定关闭。若 owner 偏好本 PR 更干净的 API,缺口已在分支上修复(head 139c9db),可直接用。两个 PR 我都不会执行关闭。

@ranxianglei ranxianglei added bug Something isn't working duplicate This issue or pull request already exists p2 scope/wiring labels Oct 25, 2026
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-10-26_input-buffer-lifetime (0a6d13d)

Latest build — install this exact version

npm install -g billion-context@0.0.6147-pr.4766.772f0f0

This exact version is immutable and reproducible. The rolling pr tag tracks the newest test build across all PRs.

Recent builds (commit → version)

commit version build
0a6d13d billion-context@0.0.6147-pr.4766.772f0f0 build
e841867 billion-context@0.0.6062-pr.4766.80481a3 build
b085be6 billion-context@0.0.5949-pr.4766.1417b43 build
139c9db billion-context@0.0.5848-pr.4766.5739bae build

Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf billion-context-pr4766.tgz
npm install -g package

Install instructions are refreshed on each push.

ci-check added 4 commits October 26, 2026 10:15
master window commits 293ae65/de5e03659 bumped kernel/package.json to
0.0.165 without regenerating the lockfiles; lock-sync re-resolution then
rewrites the stale recorded versions (root 0.0.163, kernel 0.0.164) and
fails. Pure recorded-version sync via npm install --package-lock-only;
no dependency or semantic change.

This branch has not been deployed

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

Labels

bug Something isn't working duplicate This issue or pull request already exists p2 scope/wiring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: release request input buffers before long-running responses

1 participant