/** * [ref] 件1 —— 一条 SSE 连接的**关闭清理链**接线口。 * * ## 为什么必须双监听(而不是只挂 `req`) * * `routes/tasks.ts:232` 早已成文并在生产上验过一次:**请求体一旦被读完,客户端再断开就不会有 * `req` 的 `'close'` 了** —— Node 的 readable 在 EOF 被消费掉那一刻就 autoDestroy 并把 `'close'` * 发掉了,之后的断连在 `req` 这一侧无事发生。tasks.ts 的 POST 腿当年正因此让「断连即杀 run」的 * 契约整条失效(dropped relay 照烧 token),补的就是 `res` 那一半。 * * 四个断连形(客户端 abort / 服务端 `res.destroy()` / 裸 socket RST / 半关 FIN)**实测** * (开发机 node v24.2.0;CI 跑 node 22,未在其上重测 —— 引用这些结论时按此打折),归纳出两条判据: * 1. **`res` 的 `'close'` 恒先于 `req` 的**。socket 一死先落到响应侧;`req` 那一份是 http server * 的 `abortIncoming` 补发的,晚一拍。⇒ 只挂 `req` 等于自愿多跑一拍才收摊。 * 2. **请求流被读干后,`req` 的 `'close'` 不再补发**。「读干」不限于 POST 读 body:GET 上任何人 * 调 `resume()`/for-await 同样把它烧掉(实测同形)。⇒ 只挂 `req` 的腿,其清理是否发生取决于 * 「这条路径上有没有人碰过请求流」这种**远处的、易漂的**前提,而不是自己的接线。 * * 所以正解是**两侧都挂**:`res` 那一份是真正管用的那条(恒发、且更早),`req` 那一份保留既有语义 * (某些形下它也发,且历史行为依赖它)。`routes/fleet.ts` 与 `routes/workflows.ts` 早就是这个形, * 本模块只是把那对逐字重复的接线收成一个属主,让第三条腿不必再各写一遍、也不会再漏写一半。 * * ## 为什么闸是必须的 * * 上面第 1 条的另一面:**一次断连通常两个事件都来**。清理动作(`clearInterval` / 迭代器 * `return()` / 置 `closed` 旗)本身多数幂等,但「多数」不是「全部」——一旦某个端点的 onClose 里 * 掺进不幂等的一手(计数、记一条日志、发一帧、abort 一个已被复用的控制器),重复执行就是真缺陷, * 而且是那种「本地跑不出来、线上偶发」的形。闸放在这里 = 各端点写 onClose 时不必再自证幂等。 * * 闸在**调用之前**落下(不是之后):onClose 抛出时,第二个事件不得把一个已经跑了一半的清理再跑 * 一遍 —— 半跑过的清理重入,比不跑更难诊断。 * * ## 为什么注册完还要回看一眼状态 * * 光「订阅未来事件」不够:`'close'` **只发一次**,而四条腿的接线全都排在若干次 await 之后 * (trace 是 `getRun`/`retainedFrom`,runs 是 subagent probe,sse-log 是 `retainedFrom`)。客户端 * 在那段慢查询里断开 ⇒ 两个 close 都在监听器注册**之前**烧完(实测:后装的监听器不会被补发)⇒ * 清理整条不发生,泵对着死连接轮询到 15 分钟帽。这不是假想:`sse-log.ts` 的 preamble 注里记着的 * 同一个坑,当时只把 preamble 挪到了接线之后,而接线本身前面还有别的 await。 * * 所以注册之后补一次**状态回看**:两条腿(事件订阅 + 当前状态)合起来才覆盖完整时间轴。 * * 🔴 判据只能取 `res` 侧。实测三态:窗口内断连 ⇒ `res.destroyed`/`res.closed` 皆 true;连接健在 ⇒ * 皆 false;**请求流被读干但连接健在** ⇒ `req.destroyed` 为 true 而 `res` 两位仍为 false。 * ⇒ 用 `req.destroyed` 当判据会把「读干形的活连接」当场判死 —— 误杀一条正在服务的流,比漏清理更糟。 * * ## 本模块**不**做的事(留给调用方,别在这里加) * * 不区分「正常收流的 close」与「断连的 close」:`res.end()` 之后同样会发 `'close'`。四个调用方的 * onClose 在收流后执行都是无害的(旗已无人读、interval 已清、迭代器已 return)。onClose 带**副作用** * 的腿(tasks.ts 的 park/abort 那条)必须自己按 `res.writableEnded` 判别,那条判据是**该腿的语义**, * 不是本助手的 —— 塞进这里会让「正常结束」在别的腿上被误当断连。 */ import type { IncomingMessage, ServerResponse } from "node:http"; /** * 把 `onClose` 接到这条连接的两个关闭信号上,并保证**至多执行一次**。 * * 连接在调用本函数**之前**就已经断了(接线排在慢查询之后的那种形),`onClose` 会在本函数内 * **同步**跑一次 —— 调用方据此可以假定:本函数返回后,「已断连」这件事一定已经被通知过了。 * 于是 `closed` 旗在泵进入循环前就已翻好,不需要每条腿再自己判一次 `res.destroyed`。 * * @param onClose 本连接的全部清理动作(置 `closed` 旗、`clearInterval`、迭代器 `return()` 等)。 */ export declare function bindSseLifecycle(req: IncomingMessage, res: ServerResponse, onClose: () => void): void; //# sourceMappingURL=sse-lifecycle.d.ts.map