流式文件响应 `Controller is already closed` 崩溃复盘

流式文件响应 Controller is already closed 崩溃复盘

事故文件:app/api/uploads/[filename]/route.ts 关键词:Next.js Route Handler、Readable.toWeb、Range 请求、uncaughtException

一、现象

生产环境 PM2 的 next-error.log 持续刷出同一条未捕获异常:

perl 复制代码
0|next | ⨯ uncaughtException:  [TypeError: Invalid state: Controller is already closed] {
0|next |   code: 'ERR_INVALID_STATE'
0|next | }

它有两个危险特征:

  1. uncaughtException,不是普通报错。 它没有落在任何一次请求的 try/catch 里,而是从全局冒出来的。Node 进程处于「不确定状态」,PM2 可能因此重启进程------重启窗口内所有请求 502。
  2. 高频、无规律。 没有固定的复现入口,访问量越大刷得越勤。

二、根本原因

出问题的是唯一一处「流式返回文件」的接口。原始写法:

ts 复制代码
const nodeStream = createReadStream(filePath, { start, end });
const stream = Readable.toWeb(nodeStream) as ReadableStream;
return new NextResponse(stream, { /* headers... */ });

2.1 流式响应的生命周期

用流返回响应体时,数据不是一次性写完的,而是「边读边发」:

scss 复制代码
文件 → Node Readable 流 → Web ReadableStream(controller) → HTTP 响应体 → 客户端

只要客户端还在接收,这条链路就一直在往 controllerenqueue(塞数据);读完了再 controller.close()

2.2 客户端中途断开时发生了什么

问题出在客户端在文件还没传完时断开连接。常见触发场景:

  • 用户切走 / 关闭标签页,图片、文件还没加载完;
  • 浏览器取消了一个正在进行的图片请求;
  • <video> / <audio> 拖动进度条 ------播放器会发带 Range 头的请求,拿到一段就中断,再发下一段。这是最高频的来源,尤其项目里刚加了视频功能之后。

客户端一断开,运行时会取消(cancel)响应体的 Web 流,controller 随之关闭 。但底层的 Node createReadStream 并不知情,它仍在读文件、仍在触发 data 事件,Readable.toWeb 的内部适配器就会拿着一个已经关闭的 controller 继续调用 enqueue() / close()------于是抛出:

vbnet 复制代码
TypeError: Invalid state: Controller is already closed  (ERR_INVALID_STATE)

2.3 为什么会变成 uncaughtException

这个 enqueue 调用发生在 Node 流的 data/end 事件回调内部 ,由 Readable.toWeb 自己管理。它不在我们 route.ts 那个包住 stat()try/catch 作用域里------那个 catch 只能兜住「找文件、建流」阶段的同步/await 错误,兜不住「流已经交给运行时之后,在事件回调里」抛的异常。没有任何地方接住它,它就冒泡成了全局 uncaughtException

一句话: 客户端提前断开 → 运行时关掉 controller → Node 流仍在往关闭的 controller 里写 → 抛错 → 因发生在流内部事件回调里,无人捕获 → 进程级未捕获异常。

三、如何排查

这类问题日志里只有异常类型、没有堆栈指向业务代码,排查靠的是「按特征缩小范围」:

  1. 认领错误类型。 ERR_INVALID_STATE + Controller is already closed 是 Web Streams API 的固定错误,几乎必然和「流式响应/流式写入」有关,与业务逻辑(数据库、表单)无关。看到它就先怀疑流。

  2. 搜出所有流式代码。 全局搜关键 API,定位嫌疑面:

    bash 复制代码
    grep -rn "ReadableStream\|Readable.toWeb\|createReadStream\|controller\.\|new Response(" app lib --include="*.ts" --include="*.tsx"

    本项目只命中一处,直接锁定 app/api/uploads/[filename]/route.ts

  3. 对照「断开触发」的特征验证。 该接口正好满足所有条件:返回流、支持 Range、服务的是图片/视频这类会被浏览器频繁中断的资源。因果链吻合。

  4. 本地复现(可选)。 请求一个较大文件,中途 curl--max-time 0.2 强制超时中断,或用浏览器反复拖动视频进度条,观察 next-error.log 是否复现。

四、修复

不再用 Readable.toWeb,改为手写 ReadableStream,自己掌控关闭时机:

ts 复制代码
function nodeStreamToWeb(nodeStream: Readable, signal?: AbortSignal): ReadableStream<Uint8Array> {
  let closed = false;

  const cleanup = () => {
    if (closed) return;
    closed = true;
    nodeStream.destroy(); // 断开时销毁 Node 流,停止继续读文件
  };

  return new ReadableStream<Uint8Array>({
    start(controller) {
      if (signal) {
        if (signal.aborted) cleanup();
        else signal.addEventListener("abort", cleanup, { once: true });
      }
      nodeStream.on("data", (chunk: Buffer) => {
        if (closed) return;                 // 关闭后一律短路,绝不碰已关闭的 controller
        try {
          controller.enqueue(new Uint8Array(chunk));
        } catch {
          cleanup();
          return;
        }
        if ((controller.desiredSize ?? 1) <= 0) nodeStream.pause(); // 背压
      });
      nodeStream.on("end", () => {
        if (closed) return;
        closed = true;
        try { controller.close(); } catch { /* 已关闭则忽略 */ }
      });
      nodeStream.on("error", (err) => {
        if (closed) return;
        closed = true;
        try { controller.error(err); } catch { /* 已关闭则忽略 */ }
      });
    },
    pull() {
      if (!closed) nodeStream.resume();     // 消费端有空间了再继续读
    },
    cancel() {
      cleanup();                            // 客户端断开时运行时会调用它
    },
  });
}

调用处:

ts 复制代码
const nodeStream = createReadStream(filePath, { start, end });
const stream = nodeStreamToWeb(nodeStream, req.signal);
return new NextResponse(stream, { /* headers... */ });

四个关键点:

手段 作用
closed 标志 流关闭后 data/end/error 一律短路,从源头杜绝对已关闭 controller 的写入
cancel() + req.signal 客户端断开时销毁底层 Node 流,不再空读文件、不再推数据
enqueuetry/catch 即使有竞态漏网,也降级为清理而非抛未捕获异常
desiredSize / pause+resume 背压,避免大文件/视频把内存打满

五、如何避免(通用原则)

  1. 不要裸用 Readable.toWeb() 直接返回响应体。 它不会替你处理「客户端断开后 Node 流仍在跑」的竞态。凡是返回流,都要能响应取消。

  2. 凡流式响应,必处理取消。 通过 ReadableStreamcancel() 回调和请求的 AbortSignal,在客户端断开时主动销毁上游资源(文件句柄、数据库游标、上游 fetch)。这既能避免本次异常,也能防止句柄泄漏。

  3. 对 controller 的写入操作要防御性编程。 用「已关闭」标志 + try/catch 双保险包住 enqueue/close/error,承认「流可能在任何时刻已被对端关闭」这一事实。

  4. 警惕 Range 请求场景。 服务视频/音频/大文件时,浏览器会大量发起并中断 range 请求,断开是常态而非异常,流代码必须为此设计。

  5. 兜底:给进程加全局 uncaughtException 处理器 ,只用于「记录 + 不让进程崩」,但它是最后一道网,不能替代修复具体的流代码

  6. 更优解:静态文件交给 Nginx / CDN。 上传的图片、视频这类静态资源,最好由 Nginx location /uploads/ 直接托管,让 Node 完全不参与文件流------既没有这个异常风险,吞吐和内存表现也远好于应用层转发。当前项目是通过 next.config.mjs 的 rewrite 把 /uploads/:filename 转到 API 层的,后续可考虑在反代层直接落地。

六、上线动作

  • 部署新代码后执行 pm2 restart next;
  • 观察 ~/.pm2/logs/next-error.log,确认 ERR_INVALID_STATE 不再增长;
  • 回归验证:正常加载图片、拖动视频进度条、中途关闭页面,均不再触发异常。
相关推荐
人间凡尔赛3 天前
2026 年 React Server Components 完全指南:Next.js 16 全栈开发最佳实践
前端·typescript·react·全栈·next.js
倾颜5 天前
pnpm 负责依赖,Turbo 负责任务:一次小型 Monorepo 治理的边界取舍
前端·next.js
wordpress资料库7 天前
Next.js更适合移动开发 不适合web开发
开发语言·前端·javascript·next.js
赵大仁8 天前
Next.js + Vercel AI SDK 实战:30 分钟搭出流式 Chat 页面
前端·ai·实战·react·next.js·vercel
飞天狗11 天前
Next.js 16 App Router 实战:服务端组件与流式渲染
前端·next.js
weixin_4713830313 天前
Next.js - 04 - 深入理解next.js渲染原理
前端·javascript·next.js
名字还没想好☜14 天前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
竹林81814 天前
用 wagmi v2 + Next.js 14 踩坑实录:手写一个支持多链的 NFT 市场前端
next.js
小Bk15 天前
我用 Next.js 16 + DeepSeek API 做了一个 AI 简历吐槽器,已开源
openai·next.js·deepseek