Skip to content

fix(lifecycle): don't crash process on benign broken-pipe from streaming disconnect (#1234)#1239

Merged
ding113 merged 2 commits into
devfrom
fix/1234-epipe-crash
Jun 2, 2026
Merged

fix(lifecycle): don't crash process on benign broken-pipe from streaming disconnect (#1234)#1239
ding113 merged 2 commits into
devfrom
fix/1234-epipe-crash

Conversation

@ding113

@ding113 ding113 commented Jun 2, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #1234 — Codex /v1/responses (and any other streaming) traffic could crash the entire CCH process via an uncaught write EPIPE when a downstream client disconnects mid-stream.

The final streaming Response is written to the socket by Next.js, outside any local try/catch. When the client disconnects, Node throws write EPIPE which escapes to the process-level uncaughtException handler in src/instrumentation.ts — which called process.exit(1) for every error, turning a per-request broken pipe into a full container restart (RestartCount kept climbing).

Changes

  • New src/lib/lifecycle/benign-errors.ts — zero-dependency isBenignBrokenPipeError() that recognizes EPIPE / ECONNRESET / ERR_STREAM_PREMATURE_CLOSE on the error and its cause chain (depth-limited, cycle-safe). Detection is code-only (no loose message matching) so an upstream error body that merely mentions "EPIPE" can never falsely suppress a real crash.
  • src/instrumentation.ts — both uncaughtException and unhandledRejection handlers now early-return with logger.warn (no process.exit) for benign broken-pipe errors; genuine errors still fail-fast with exit(1) exactly as before. registerCrashDiagnostics is exported for testability. The rejection handler checks the original reason (before it is wrapped into an Error) so a non-Error rejection like { code: "EPIPE" } keeps its code.
  • src/app/v1/_lib/proxy/errors.ts — added EPIPE to TRANSPORT_ERROR_CODES so isTransportError() (and therefore categorizeErrorAsyncSYSTEM_ERROR) classifies it consistently, matching the forwarder's existing disconnect handling.

Why this is safe

The whitelist is narrow (3 socket/stream disconnect codes only). Every other uncaught exception / unhandled rejection still triggers the diagnostic report + process.exit(1) fail-fast, so genuine logic bugs are unaffected. This aligns the global handler with the per-stream disconnect handling already present in forwarder.ts.

Tests

  • tests/unit/benign-broken-pipe-error.test.ts — predicate coverage incl. cause chains, cyclic-cause safety, plain-object carriers, and the critical negative case (message-only "EPIPE" with no code must not be benign).
  • tests/unit/instrumentation-crash-handler.test.ts — drives the actual registered handlers: benign codes log warn and do not exit; generic errors and non-benign codes still exit(1).
  • tests/unit/transport-error-detection.test.ts — EPIPE regression for isTransportError.

Verification (local, full)

  • bun run typecheck
  • bun run lint
  • bun run build ✓ (production / Turbopack standalone)
  • bun run test ✓ — 6191 passed, 13 skipped (691 files)

🤖 Generated with Claude Code

Greptile Summary

This PR fixes a container-restart regression (#1234) where streaming client disconnects could crash the entire CCH process via an uncaught write EPIPE escaping to the process-level uncaughtException handler, which previously called process.exit(1) for every error.

  • src/lib/lifecycle/benign-errors.ts — new zero-dependency predicate (getBenignBrokenPipeCode) that detects EPIPE (and only EPIPE) on the error or its cause chain (depth-limited, cycle-safe); deliberately excludes ECONNRESET / ERR_STREAM_PREMATURE_CLOSE at the process level to preserve fail-fast semantics for ambiguous infrastructure errors.
  • src/instrumentation.ts — both uncaughtException and unhandledRejection handlers now early-return with logger.warn for benign broken-pipe codes; the rejection path evaluates the raw reason before wrapping it into new Error(String(reason)), preserving the code field for plain-object rejections like { code: \"EPIPE\" }.
  • The PR description also states EPIPE was added to TRANSPORT_ERROR_CODES in src/app/v1/_lib/proxy/errors.ts with a companion regression test, but neither change is present in the diff — the set in the current file still lacks \"EPIPE\".

Confidence Score: 5/5

Core process-stability fix is correct and well-tested; the only gap is a stated change to errors.ts that was not included in the diff.

The process-level EPIPE suppression logic is narrow, correctly implemented, and thoroughly tested. The benign-error predicate is code-only (no loose message matching), restricted to the write-side EPIPE code, and cycle-safe. The main omission — adding EPIPE to TRANSPORT_ERROR_CODES — is an incomplete part of the stated scope but does not introduce new breakage; the existing catch-all SYSTEM_ERROR path in categorizeErrorAsync still handles it correctly in the common case.

src/app/v1/_lib/proxy/errors.ts — the stated EPIPE addition to TRANSPORT_ERROR_CODES and its companion test in tests/unit/transport-error-detection.test.ts were described in the PR but not applied.

Important Files Changed

Filename Overview
src/lib/lifecycle/benign-errors.ts New module implementing getBenignBrokenPipeCode / isBenignBrokenPipeError; correctly restricts benign classification to EPIPE-only (write-side), traverses cause chains up to 5 levels with cycle safety, and intentionally excludes ECONNRESET/ERR_STREAM_PREMATURE_CLOSE at the process level.
src/instrumentation.ts Both uncaughtException and unhandledRejection handlers gain an early-return path for benign broken-pipe errors; non-Error rejections like { code: 'EPIPE' } are correctly evaluated before wrapping into new Error(String(reason)), preserving the code field and avoiding [object Object] in the warn log.
tests/unit/benign-broken-pipe-error.test.ts Comprehensive predicate coverage: EPIPE direct and in cause chain, cyclic cause safety, ECONNRESET/ERR_STREAM_PREMATURE_CLOSE negative cases, message-only EPIPE negative case, and plain-object carriers.
tests/unit/instrumentation-crash-handler.test.ts Drives the actual registered handlers via process.on spy; verifies EPIPE logs warn and does not call process.exit(1), while ECONNRESET, ERR_STREAM_PREMATURE_CLOSE, and generic errors still exit(1). Regression for the non-Error { code: EPIPE } plain-object path is also covered.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Node.js emits uncaughtException / unhandledRejection] --> B[registerCrashDiagnostics handler]
    B --> C{getBenignBrokenPipeCode check EPIPE on error or cause chain up to 5 deep}
    C -- benign code found EPIPE only --> D[logger.warn no process.exit]
    C -- no match --> E{writeReport writeFatalStderr}
    E --> F[logger.fatal]
    F --> G[process.exit 1]
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
src/app/v1/_lib/proxy/errors.ts:747-762
**Stated `EPIPE` addition to `TRANSPORT_ERROR_CODES` was not applied**

The PR description explicitly states: "added `EPIPE` to `TRANSPORT_ERROR_CODES` so `isTransportError()` … classifies it consistently, matching the forwarder's existing disconnect handling" — and lists a companion test in `tests/unit/transport-error-detection.test.ts`. Neither change appears in the diff, and the current set still does not contain `"EPIPE"`.

Without this, a proxy-layer EPIPE that reaches `categorizeErrorAsync` skips the explicit `isTransportError()` guard at priority 1.5. If any user-defined error rule happens to match the error message (e.g., a pattern containing `"EPIPE"`), the error gets routed as `NON_RETRYABLE_CLIENT_ERROR` (no retry, returned to client as an error) instead of `SYSTEM_ERROR` (retry once, then fail). The catch-all `SYSTEM_ERROR` path at the bottom of `categorizeErrorAsync` still handles it correctly when no rule matches, but that implicit fallback is fragile compared to the explicit guard the PR intended.

Reviews (2): Last reviewed commit: "fix(crash-handler): suppress exit only f..." | Re-trigger Greptile

When a downstream client disconnects during a streaming response (e.g.
/v1/responses), the socket write can fail with EPIPE, ECONNRESET, or
ERR_STREAM_PREMATURE_CLOSE. Previously these errors escaped to the
process-level uncaughtException/unhandledRejection handlers and caused a
process.exit(1), restarting the container.

Now isBenignBrokenPipeError recognizes these disconnect codes (including
through error.cause chains) so the crash handlers can safely log and
return, treating the event as a per-request transport close rather than
a fatal crash. EPIPE is also added to isTransportError for consistent
classification.

Closes #1234
@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

该 PR 新增良性断管错误检测能力,防止客户端流断连时 Node.js 的 EPIPE 异常导致整个 CCH 进程重启。实现包括一个新的错误检测库(仅识别 EPIPE),与进程级崩溃处理器的集成(区分客户端断连与真正错误),以及完整的单元测试覆盖。

Changes

良性断管错误处理

Layer / File(s) Summary
良性断管错误检测库
src/lib/lifecycle/benign-errors.ts, tests/unit/benign-broken-pipe-error.test.ts
新增 getBenignBrokenPipeCode() 从错误及其 cause 链(最多 5 层)中提取良性错误码,仅 EPIPE 被判定为良性;isBenignBrokenPipeError() 包装为布尔判定。单元测试覆盖顶层码、嵌套 cause 链、循环防护、非 Error 输入与消息文本误判。
崩溃处理器接入与过滤
src/instrumentation.ts, tests/unit/instrumentation-crash-handler.test.ts
registerCrashDiagnostics 改为导出函数,在 uncaughtExceptionunhandledRejection 处理器中接入良性检测:EPIPE 匹配时仅 warn 后返回,其他错误则原流程 fatal 与 exit(1);unhandledRejection 对非 Error 拒因分支处理以保留 code。单元测试验证良性忽略与致命分支、非 Error rejection、stderr 写入与日志元信息。

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • ding113/claude-code-hub#1152:同样修改 src/instrumentation.ts 的进程级 uncaughtException/unhandledRejection 处理路径,本 PR 新增良性 EPIPE 过滤而该 PR 扩展了诊断报告生成与生命周期标记。
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed 标题清晰准确地反映了主要变更:修复进程因良性断管错误(来自流式断连)而崩溃的问题,与变更集完全相关。
Linked Issues check ✅ Passed 代码变更完全满足 #1234 的需求:识别 EPIPE 等良性断管错误,在全局异常处理器中提前返回避免进程退出,同时保留对真实错误的失败快进语义。
Out of Scope Changes check ✅ Passed 所有变更均在 #1234 范围内:新增良性错误判定模块、修改崩溃诊断处理器、更新传输错误分类,以及对应的单元和集成测试,无超出范围的变更。
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
Description check ✅ Passed The pull request description is directly related to the changeset, clearly explaining the fix for a process crash issue caused by benign broken-pipe errors during streaming client disconnects.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1234-epipe-crash

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 and usage tips.

@github-actions github-actions Bot added bug Something isn't working area:core area:provider labels Jun 2, 2026

@gemini-code-assist gemini-code-assist 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

This pull request introduces mechanism to handle benign broken-pipe and client-disconnect errors (such as EPIPE, ECONNRESET, and ERR_STREAM_PREMATURE_CLOSE) gracefully without crashing the process. It adds a utility to detect these errors by traversing the error cause chain, integrates this check into the process-level crash handlers in instrumentation.ts, and adds comprehensive unit tests. The review feedback suggests extracting the matched error code from the cause chain using a new helper function (getBenignBrokenPipeCode) to ensure the correct nested error code is logged instead of resulting in undefined.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/lib/lifecycle/benign-errors.ts Outdated
Comment on lines +43 to +57
export function isBenignBrokenPipeError(err: unknown): boolean {
let current: unknown = err;
for (let depth = 0; depth <= MAX_CAUSE_DEPTH && current != null; depth++) {
if (typeof current === "object") {
const code = (current as { code?: unknown }).code;
if (typeof code === "string" && BENIGN_BROKEN_PIPE_CODES.has(code)) {
return true;
}
current = (current as { cause?: unknown }).cause;
continue;
}
break;
}
return false;
}

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

If a benign error is wrapped inside a cause chain (e.g., a FetchError wrapping an EPIPE error), isBenignBrokenPipeError will correctly return true, but logging (err as NodeJS.ErrnoException).code in instrumentation.ts will result in undefined because the code is nested.

By extracting the actual matched code via a new helper getBenignBrokenPipeCode, we can log the correct error code that triggered the suppression, making troubleshooting much easier.

Suggested change
export function isBenignBrokenPipeError(err: unknown): boolean {
let current: unknown = err;
for (let depth = 0; depth <= MAX_CAUSE_DEPTH && current != null; depth++) {
if (typeof current === "object") {
const code = (current as { code?: unknown }).code;
if (typeof code === "string" && BENIGN_BROKEN_PIPE_CODES.has(code)) {
return true;
}
current = (current as { cause?: unknown }).cause;
continue;
}
break;
}
return false;
}
export function getBenignBrokenPipeCode(err: unknown): string | undefined {
let current: unknown = err;
for (let depth = 0; depth <= MAX_CAUSE_DEPTH && current != null; depth++) {
if (typeof current === "object") {
const code = (current as { code?: unknown }).code;
if (typeof code === "string" && BENIGN_BROKEN_PIPE_CODES.has(code)) {
return code;
}
current = (current as { cause?: unknown }).cause;
continue;
}
break;
}
return undefined;
}
export function isBenignBrokenPipeError(err: unknown): boolean {
return getBenignBrokenPipeCode(err) !== undefined;
}

Comment thread src/instrumentation.ts Outdated
*/

import { startCacheCleanup } from "@/lib/cache/session-cache";
import { isBenignBrokenPipeError } from "@/lib/lifecycle/benign-errors";

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

Import getBenignBrokenPipeCode to extract the actual matched error code from the cause chain for logging.

Suggested change
import { isBenignBrokenPipeError } from "@/lib/lifecycle/benign-errors";
import { getBenignBrokenPipeCode, isBenignBrokenPipeError } from "@/lib/lifecycle/benign-errors";

Comment thread src/instrumentation.ts Outdated
Comment on lines +87 to +95
if (isBenignBrokenPipeError(err)) {
logger.warn("[Lifecycle] ignored uncaught client disconnect", {
error: err.message,
errorName: err.name,
errorCode: (err as NodeJS.ErrnoException).code,
stack: err.stack,
});
return;
}

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

Use getBenignBrokenPipeCode to extract and log the actual error code that triggered the suppression, even if it is nested deep in the cause chain.

Suggested change
if (isBenignBrokenPipeError(err)) {
logger.warn("[Lifecycle] ignored uncaught client disconnect", {
error: err.message,
errorName: err.name,
errorCode: (err as NodeJS.ErrnoException).code,
stack: err.stack,
});
return;
}
const benignCode = getBenignBrokenPipeCode(err);
if (benignCode) {
logger.warn("[Lifecycle] ignored uncaught client disconnect", {
error: err.message,
errorName: err.name,
errorCode: benignCode,
stack: err.stack,
});
return;
}

Comment thread src/instrumentation.ts Outdated
Comment on lines +118 to +126
if (isBenignBrokenPipeError(reason)) {
logger.warn("[Lifecycle] ignored unhandled client disconnect", {
error: err.message,
errorName: err.name,
errorCode: (reason as NodeJS.ErrnoException)?.code ?? (err as NodeJS.ErrnoException).code,
stack: err.stack,
});
return;
}

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

Use getBenignBrokenPipeCode to extract and log the actual error code that triggered the suppression, avoiding potential undefined values when the code is nested in the cause chain of the rejection reason.

Suggested change
if (isBenignBrokenPipeError(reason)) {
logger.warn("[Lifecycle] ignored unhandled client disconnect", {
error: err.message,
errorName: err.name,
errorCode: (reason as NodeJS.ErrnoException)?.code ?? (err as NodeJS.ErrnoException).code,
stack: err.stack,
});
return;
}
const benignCode = getBenignBrokenPipeCode(reason);
if (benignCode) {
logger.warn("[Lifecycle] ignored unhandled client disconnect", {
error: err.message,
errorName: err.name,
errorCode: benignCode,
stack: err.stack,
});
return;
}

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2f128bca44

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/lib/lifecycle/benign-errors.ts Outdated
Comment on lines +26 to +27
"ECONNRESET",
"ERR_STREAM_PREMATURE_CLOSE",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep global crash handling fatal for non-client resets

When any uncaught exception or unhandled rejection anywhere in the process carries ECONNRESET or ERR_STREAM_PREMATURE_CLOSE, registerCrashDiagnostics now returns before writing a report or exiting. These codes are not specific to downstream Response writes; upstream provider, Redis, DB, or other stream/socket failures can use the same codes, and the process-level handler has no request/socket context to prove they are client disconnects. In those scenarios a real unhandled infrastructure bug is reduced to a warning and the process continues in an unknown state instead of preserving the previous fail-fast behavior; please scope the suppression to the actual broken-pipe/write path or add context checks before swallowing these broader codes.

Useful? React with 👍 / 👎.

Comment thread src/lib/lifecycle/benign-errors.ts Outdated
Comment on lines +26 to +27
"ECONNRESET",
"ERR_STREAM_PREMATURE_CLOSE",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 ECONNRESET may also originate from upstream connections

ECONNRESET is not exclusively a client-disconnect signal — it can also be raised when an upstream TCP peer (e.g., a database, Redis, or the AI-provider API) resets the connection. If any such socket emits an 'error' event without a listener, or a promise carrying that error goes unhandled, it will be classified as benign here and swallowed with only a warn log instead of triggering a container restart. Depending on how thoroughly upstream clients (pg, ioredis, undici) guard their sockets, a transient infrastructure reset that should cause a restart could be silently absorbed.

Intentional? If so, adding a comment noting this tradeoff (or narrowing the set to EPIPE + ERR_STREAM_PREMATURE_CLOSE only, since both are write-side / close-side signals) would make the design decision explicit.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/lifecycle/benign-errors.ts
Line: 26-27

Comment:
**`ECONNRESET` may also originate from upstream connections**

`ECONNRESET` is not exclusively a client-disconnect signal — it can also be raised when an *upstream* TCP peer (e.g., a database, Redis, or the AI-provider API) resets the connection. If any such socket emits an `'error'` event without a listener, or a promise carrying that error goes unhandled, it will be classified as benign here and swallowed with only a `warn` log instead of triggering a container restart. Depending on how thoroughly upstream clients (pg, ioredis, undici) guard their sockets, a transient infrastructure reset that should cause a restart could be silently absorbed.

Intentional? If so, adding a comment noting this tradeoff (or narrowing the set to `EPIPE` + `ERR_STREAM_PREMATURE_CLOSE` only, since both are write-side / close-side signals) would make the design decision explicit.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread src/instrumentation.ts
Comment on lines 113 to 128
process.on("unhandledRejection", (reason: unknown) => {
const err = reason instanceof Error ? reason : new Error(String(reason));
// 同 uncaughtException:良性断连以 rejection 形式逃逸时同样不应使进程退出。
// 注意:对原始 reason 判定(而非 wrap 后的 err),否则 { code: "EPIPE" } 之类
// 非 Error 拒因在 new Error(String(reason)) 后会丢失 code。
if (isBenignBrokenPipeError(reason)) {
logger.warn("[Lifecycle] ignored unhandled client disconnect", {
error: err.message,
errorName: err.name,
errorCode: (reason as NodeJS.ErrnoException)?.code ?? (err as NodeJS.ErrnoException).code,
stack: err.stack,
});
return;
}

const reportPath = writeReport("unhandledRejection", err);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Warn-log error field shows "[object Object]" for plain-object rejections

new Error(String(reason)) is constructed unconditionally before the benign guard, so for a non-Error rejection like { code: "EPIPE" }, String(reason) produces "[object Object]" and that becomes err.message. The error field in the logger.warn call will therefore be "[object Object]" rather than anything meaningful. Moving the err construction to after the benign check (or deriving the message directly from reason) fixes this for the warn path without affecting the fatal path.

Suggested change
process.on("unhandledRejection", (reason: unknown) => {
const err = reason instanceof Error ? reason : new Error(String(reason));
// 同 uncaughtException:良性断连以 rejection 形式逃逸时同样不应使进程退出。
// 注意:对原始 reason 判定(而非 wrap 后的 err),否则 { code: "EPIPE" } 之类
// 非 Error 拒因在 new Error(String(reason)) 后会丢失 code。
if (isBenignBrokenPipeError(reason)) {
logger.warn("[Lifecycle] ignored unhandled client disconnect", {
error: err.message,
errorName: err.name,
errorCode: (reason as NodeJS.ErrnoException)?.code ?? (err as NodeJS.ErrnoException).code,
stack: err.stack,
});
return;
}
const reportPath = writeReport("unhandledRejection", err);
process.on("unhandledRejection", (reason: unknown) => {
// 同 uncaughtException:良性断连以 rejection 形式逃逸时同样不应使进程退出。
// 注意:对原始 reason 判定(而非 wrap 后的 err),否则 { code: "EPIPE" } 之类
// 非 Error 拒因在 new Error(String(reason)) 后会丢失 code。
if (isBenignBrokenPipeError(reason)) {
const code = (reason as NodeJS.ErrnoException)?.code;
logger.warn("[Lifecycle] ignored unhandled client disconnect", {
error: reason instanceof Error ? reason.message : String(reason),
errorName: reason instanceof Error ? reason.name : typeof reason,
errorCode: code,
stack: reason instanceof Error ? reason.stack : undefined,
});
return;
}
const err = reason instanceof Error ? reason : new Error(String(reason));
const reportPath = writeReport("unhandledRejection", err);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/instrumentation.ts
Line: 113-128

Comment:
**Warn-log `error` field shows `"[object Object]"` for plain-object rejections**

`new Error(String(reason))` is constructed unconditionally before the benign guard, so for a non-`Error` rejection like `{ code: "EPIPE" }`, `String(reason)` produces `"[object Object]"` and that becomes `err.message`. The `error` field in the `logger.warn` call will therefore be `"[object Object]"` rather than anything meaningful. Moving the `err` construction to after the benign check (or deriving the message directly from `reason`) fixes this for the warn path without affecting the fatal path.

```suggestion
  process.on("unhandledRejection", (reason: unknown) => {
    // 同 uncaughtException:良性断连以 rejection 形式逃逸时同样不应使进程退出。
    // 注意:对原始 reason 判定(而非 wrap 后的 err),否则 { code: "EPIPE" } 之类
    // 非 Error 拒因在 new Error(String(reason)) 后会丢失 code。
    if (isBenignBrokenPipeError(reason)) {
      const code = (reason as NodeJS.ErrnoException)?.code;
      logger.warn("[Lifecycle] ignored unhandled client disconnect", {
        error: reason instanceof Error ? reason.message : String(reason),
        errorName: reason instanceof Error ? reason.name : typeof reason,
        errorCode: code,
        stack: reason instanceof Error ? reason.stack : undefined,
      });
      return;
    }

    const err = reason instanceof Error ? reason : new Error(String(reason));
    const reportPath = writeReport("unhandledRejection", err);
```

How can I resolve this? If you propose a fix, please make it concise.

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

🧪 测试结果

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

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

@github-actions github-actions Bot added the size/M Medium PR (< 500 lines) label Jun 2, 2026

@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

No significant issues identified in this PR. The fix is well-scoped, thoroughly tested, and correctly addresses the EPIPE crash issue without compromising the fail-fast behavior for genuine errors.

PR Size: M

  • Lines changed: 335 (334 additions, 1 deletion)
  • Files changed: 6

Review Coverage

  • Logic and correctness - Clean. The isBenignBrokenPipeError predicate is code-only (no message matching), depth-limited, and cycle-safe. The uncaughtException/unhandledRejection handlers correctly early-return for benign codes while preserving process.exit(1) for genuine errors.
  • Security (OWASP Top 10) - Clean. No new attack surface; the whitelist is narrow (3 codes only) and no user input is evaluated.
  • Error handling - Clean. Errors are logged at warn level with errorCode for benign cases; fatal path remains unchanged for non-benign errors. The cause chain traversal is bounded.
  • Type safety - Clean. No any usage; the unknown parameter is safely narrowed with typeof checks.
  • Documentation accuracy - Clean. Comments accurately describe the behavior and the trade-offs (e.g., explicit note about avoiding message matching to prevent false suppression).
  • Test coverage - Adequate. Three new test files cover the predicate (including cause chains, cycles, plain objects, and negative cases), the handler wiring (benign vs. genuine via spy), and the transport error classification regression.
  • Code clarity - Good. The benign-errors.ts module is intentionally zero-dependency and self-contained. The registerCrashDiagnostics export is justified for testability.

Minor Note (Author's Discretion)

The unhandledRejection handler constructs err before the benign check, so for a plain-object rejection like { code: "EPIPE" }, the logger.warn error field receives "[object Object]" rather than a meaningful message. The errorCode field is correct ("EPIPE"), so the log remains functional. This is a low-priority logging quality improvement.


Automated review by Claude AI

Restrict process-level crash-handler suppression to EPIPE (write-side socket
pipe) only. ECONNRESET and ERR_STREAM_PREMATURE_CLOSE can originate
upstream (providers, DB, Redis) where suppressing exit hides real
infrastructure failures; they now trigger fail-fast.

- Narrow BENIGN_BROKEN_PIPE_CODES to ["EPIPE"] in benign-errors.ts.
- Add getBenignBrokenPipeCode to extract the matched code from nested
  causes for accurate logging.
- Fix unhandledRejection logging to avoid "[object Object]" for non-Error
  rejections with EPIPE.
- Remove EPIPE from isTransportError (proxy layer handles communication
  errors; crash handler covers uncaught EPIPE separately).
- Update unit tests to verify EPIPE-only suppression, fail-fast for
  ambiguous codes, and correct logging.

Refs #1234
@ding113

ding113 commented Jun 2, 2026

Copy link
Copy Markdown
Owner Author

Review feedback addressed (pushed in 97d162d)

Thanks @gemini-code-assist, @chatgpt-codex-connector, and @greptile-apps. All points are now resolved:

1. P1/P2 — ECONNRESET / ERR_STREAM_PREMATURE_CLOSE are not exclusively downstream signals (codex P1, greptile P2)

Agreed — this was the right call. The global crash handler has no request/socket context, so suppressing these codes process-wide could mask a real upstream (provider/DB/Redis) failure and leave the process running in an unknown state, defeating the #1147 fail-fast intent.

The benign set is now EPIPE only. Rationale: EPIPE is a write-side error — it can only occur when we write to a socket whose peer has closed. In this proxy the only large unguarded write that escapes to process scope is Next.js writing the streaming response to the client, so a process-level EPIPE is overwhelmingly a downstream disconnect. ECONNRESET / ERR_STREAM_PREMATURE_CLOSE (direction-ambiguous) now stay fatal and are handled in-context by the forwarder / isTransportError where request context exists. This also fully fixes the reported crash, which was specifically write EPIPE.

2. Log the actual matched code from the cause chain (gemini ×4)

Added getBenignBrokenPipeCode(err) which returns the matched code (walking the cause chain) and is reused by isBenignBrokenPipeError. Both handlers now log the real code even when it is nested, instead of a top-level undefined.

3. "[object Object]" in the warn log for non-Error rejections (greptile)

The unhandledRejection handler no longer constructs new Error(String(reason)) before the benign check. For a non-Error rejection like { code: "EPIPE" } it now logs error: "non-Error rejection (EPIPE)" with errorName: typeof reason, and a regression test asserts the message is never "[object Object]".

4. Self-review follow-ups

  • Reverted the EPIPE addition to isTransportError — it was unnecessary for the crash fix and changed proxy-layer classification/branching (response-handler.ts stream-error path). The PR is now scoped purely to the crash handler.
  • Added fail-fast tests locking in that ECONNRESET / ERR_STREAM_PREMATURE_CLOSE still exit(1), and that the fatal path still writes diagnostics.

Local verification (full): typecheck ✓ · lint ✓ · build ✓ · test ✓ (6194 passed / 13 skipped).

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Thanks for the update, @ding113. The refinements to the benign error set and the handling of non-Error rejections are excellent. By restricting the benign set to EPIPE and ensuring the logging captures the actual cause-chain code without the [object Object] artifact, you've significantly improved the reliability of the crash diagnostics while maintaining the necessary fail-fast behavior for ambiguous transport errors. The added regression tests for the cause chain and plain-object rejections provide solid coverage for these edge cases. This looks ready to go.

@greptile-apps

greptile-apps Bot commented Jun 2, 2026

Copy link
Copy Markdown

All changes look excellent — this is exactly the right resolution.

Narrowing the benign set to EPIPE only is the correct call. The write-side-only semantic is a clean, defensible invariant, and keeping ECONNRESET / ERR_STREAM_PREMATURE_CLOSE fatal with in-context handling via the forwarder is the right tradeoff for a proxy with upstream dependencies.

The "[object Object]" fix is clean — logging "non-Error rejection (EPIPE)" is unambiguous and the regression test asserting the message is never "[object Object]" locks it in well.

Reverting the EPIPE addition to isTransportError is also the right scoping decision — keeps this PR's blast radius minimal and avoids unintended side effects in the proxy classification path.

LGTM. ✓

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

🧪 测试结果

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

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

@ding113
ding113 merged commit 8ceb31d into dev Jun 2, 2026
10 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Claude Code Hub Roadmap Jun 2, 2026
@ding113
ding113 deleted the fix/1234-epipe-crash branch June 2, 2026 13:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core area:provider 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