流式文件响应 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 | }
它有两个危险特征:
- 是
uncaughtException,不是普通报错。 它没有落在任何一次请求的try/catch里,而是从全局冒出来的。Node 进程处于「不确定状态」,PM2 可能因此重启进程------重启窗口内所有请求 502。 - 高频、无规律。 没有固定的复现入口,访问量越大刷得越勤。
二、根本原因
出问题的是唯一一处「流式返回文件」的接口。原始写法:
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 响应体 → 客户端
只要客户端还在接收,这条链路就一直在往 controller 里 enqueue(塞数据);读完了再 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 里写 → 抛错 → 因发生在流内部事件回调里,无人捕获 → 进程级未捕获异常。
三、如何排查
这类问题日志里只有异常类型、没有堆栈指向业务代码,排查靠的是「按特征缩小范围」:
-
认领错误类型。
ERR_INVALID_STATE+Controller is already closed是 Web Streams API 的固定错误,几乎必然和「流式响应/流式写入」有关,与业务逻辑(数据库、表单)无关。看到它就先怀疑流。 -
搜出所有流式代码。 全局搜关键 API,定位嫌疑面:
bashgrep -rn "ReadableStream\|Readable.toWeb\|createReadStream\|controller\.\|new Response(" app lib --include="*.ts" --include="*.tsx"本项目只命中一处,直接锁定
app/api/uploads/[filename]/route.ts。 -
对照「断开触发」的特征验证。 该接口正好满足所有条件:返回流、支持
Range、服务的是图片/视频这类会被浏览器频繁中断的资源。因果链吻合。 -
本地复现(可选)。 请求一个较大文件,中途
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 流,不再空读文件、不再推数据 |
enqueue 包 try/catch |
即使有竞态漏网,也降级为清理而非抛未捕获异常 |
desiredSize / pause+resume |
背压,避免大文件/视频把内存打满 |
五、如何避免(通用原则)
-
不要裸用
Readable.toWeb()直接返回响应体。 它不会替你处理「客户端断开后 Node 流仍在跑」的竞态。凡是返回流,都要能响应取消。 -
凡流式响应,必处理取消。 通过
ReadableStream的cancel()回调和请求的AbortSignal,在客户端断开时主动销毁上游资源(文件句柄、数据库游标、上游 fetch)。这既能避免本次异常,也能防止句柄泄漏。 -
对 controller 的写入操作要防御性编程。 用「已关闭」标志 +
try/catch双保险包住enqueue/close/error,承认「流可能在任何时刻已被对端关闭」这一事实。 -
警惕
Range请求场景。 服务视频/音频/大文件时,浏览器会大量发起并中断 range 请求,断开是常态而非异常,流代码必须为此设计。 -
兜底:给进程加全局
uncaughtException处理器 ,只用于「记录 + 不让进程崩」,但它是最后一道网,不能替代修复具体的流代码。 -
更优解:静态文件交给 Nginx / CDN。 上传的图片、视频这类静态资源,最好由 Nginx
location /uploads/直接托管,让 Node 完全不参与文件流------既没有这个异常风险,吞吐和内存表现也远好于应用层转发。当前项目是通过next.config.mjs的 rewrite 把/uploads/:filename转到 API 层的,后续可考虑在反代层直接落地。
六、上线动作
- 部署新代码后执行
pm2 restart next; - 观察
~/.pm2/logs/next-error.log,确认ERR_INVALID_STATE不再增长; - 回归验证:正常加载图片、拖动视频进度条、中途关闭页面,均不再触发异常。