# 僵死/半开连接（ESTAB 但零吞吐）治理与卡死现场取证

- 日期：2026-09-12
- 对象：本机下载中心 8899（`~/Downloads/download-server.js`）
- 起因：用户复盘此前故障：「仍挂着 7 个来自 192.168.31.110 的 ESTAB 连接，但都无吞吐（TCP 收发送缓冲为 0），CPU 仍 ~94%，进程在空转」
- 前置文档：《2026-09-12-上传并发卡死根因与修复.md》

---

## 一、先纠正因果：连接是受害者，不是元凶

**一个空闲的已建立连接本身不消耗 CPU**：没有数据可读、没有事件可派发，epoll 不会持续唤醒它。所以「7 条 ESTAB 连接 + CPU 94%」这个组合里，CPU 94% 只可能来自**别处**——事件循环当时正被独占/空转，于是：

- 所有套接字既读不进也写不出 → 外部观察就是「连接都还在、但全部零吞吐、收发队列为 0」；
- CPU 却被打满（同步大块内存拷贝 + 磁盘等待）。

那 7 条连接是**受害者**。上一轮已定位并修复的最可能元凶正是**同步合并**（`readFileSync(64MB)` + `writeSync` 逐块合并数 GB 文件）：它同时解释了三件事——CPU 打满、事件循环停摆（连接全部僵住）、持续时间与合并体积成正比。修复后实测：3 个文件并发合并期间 `/healthz` 最大延迟 3ms。

**结论：不要把「连接多/连接挂着」当成卡死的原因去治；要治的是「谁占住了事件循环」。**

## 二、但你指出的缺陷真实存在，已补齐

排查确认服务端此前**没有任何读空闲判定**：

- `server.timeout` 未设置（Node 默认 0 = 永不超时）
- 上传请求没有 idle 超时
- 没有连接跟踪与清理逻辑
- `req.on('aborted')` 未处理

因此客户端掉线/关页/WiFi 闪断/网卡切换后，TCP 不会立刻释放，服务端就把这些「ESTAB 但零吞吐」的连接永久挂着，并残留写了一半的 `.part`。

### 三层兜底（已实现）

| 层 | 机制 | 参数 |
|---|---|---|
| 1 | 连接级 TCP keepalive + `setNoDelay` | `setKeepAlive(true, 30000)` |
| 2 | **每 15s 巡检**，销毁「上传中且超过阈值无数据」的连接 | `DL_STALE_UPLOAD_MS`（默认 120000ms） |
| 3 | 请求 `error`/`close` 时清理未完成的 `.part`（避免坏块污染续传会话） | — |

关键设计：**只杀「上传中」的连接**（socket 标记 `kind='upload'`），任何新请求开始即重置为 `idle`，因此长时间下载、WMV 转码、Office 预览等**不会被误杀**。

## 三、可观测性：下次卡死能直接定位

- `GET /healthz` 新增连接维度：
  `{"ok":true,"pid":…,"uptime":…,"rssMB":…,"mergeActive":…,"mergeQueued":…,"sockets":N,"uploadSockets":N,"staleUploads":N}`
- **事件循环滞后监控**在阻塞 >5s 时会连现场一起记（阻塞时长 + 套接字构成 + 合并队列 + 内存），例如：
  `[warn] 事件循环阻塞 8432ms（阈值 5000ms） | 套接字 7（上传中 6） | mergeActive=2 mergeQueued=1 | rss=812MB`
  —— 再出现 94% CPU，日志会直接指出当时卡在什么状态。
- 巡检定时器 `dl-healthcheck.timer` 在服务真正卡死时 **60 秒内自动重启**（实测 67s 恢复）。

## 四、验证结果

### 1. 僵死连接自动判死（本次新增，精确复现你的场景）
用一个客户端发出 `/api/upload/chunk` 请求头 + 256KB body 后**停住不发**（不发 FIN、不再发数据，制造「ESTAB 零吞吐」）：

```
起始：sockets=2 uploadSockets=1 staleUploads=0 半成品.part存在=true
t+110s  … uploadSockets=1 staleUploads=0 半成品.part存在=true
t+120s  … uploadSockets=1 staleUploads=1 半成品.part存在=true   ← 超过 120s 阈值
t+122s  客户端侧：连接已断开（ECONNRESET）                      ← 服务端主动判死
t+130s  sockets=1 uploadSockets=0 staleUploads=0 半成品.part存在=false  ← 垃圾已清理
```
服务端日志证据：
```
[warn] 清理僵死上传连接 127.0.0.1:53990（122s 无数据）→ 断开并丢弃未完成分块
```
→ **服务端现在会自己判死并清理，不再需要人工重启。**

### 2. 回归测试
- 上传功能全套 23 项：**23/23 通过**（跳过去重、改名保存、分块续传、子目录落盘、页面注入）
- 服务与巡检定时器：`active / active`
- 在传资产无损：45 个分块会话（65GB）全部在册

## 五、关于 192.168.31.110

它是在线的上传客户端（ping 1ms，MAC `00:e0:70:b9:41:97`）。**注意：该主机同时出现在本机的有线 `enp3s0f1` 与无线 `wlp4s0` 两个接口上** —— 这条线索很重要：

> 笔记本在**有线/无线之间切换**（或 WiFi 漫游到另一个 AP）时，旧接口上的 TCP 连接在服务端不会收到 FIN，会以 ESTAB 状态残留且零吞吐 —— 与「7 条挂着的连接」完全吻合。这类连接服务端必须自己能判死，本次的三层兜底正是针对这种场景。

可选的加固（当前未改，需要再快可自行启用）：内核级更快回收
```bash
sudo sysctl -w net.ipv4.tcp_keepalive_time=120 net.ipv4.tcp_keepalive_intvl=30 net.ipv4.tcp_keepalive_probes=3
```
（约 2.5 分钟由内核判定对端消失；应用层已有 30s keepalive + 120s 空闲判死，通常够用。）

## 六、参数与运维

可调参数（`/etc/systemd/system/dl-server.service` 的 `Environment=`）：
- `DL_STALE_UPLOAD_MS`（默认 120000）：上传连接无数据多久判死；网络抖动大/客户端可能长时间挂起时可调大
- `DL_MERGE_CONCURRENCY`（默认 2）、`DL_LAG_WARN_MS`（默认 5000）

```bash
curl -s --noproxy '*' http://127.0.0.1:8899/healthz   # 连接数/上传连接/合并队列/内存
tail -f ~/Downloads/dl-server.log                      # 关注 [warn] 事件循环阻塞 / 清理僵死上传连接
systemctl status dl-server dl-healthcheck.timer
```

## 七、相关文件

- 服务端：`~/Downloads/download-server.js`（`liveSockets`/`markSocket`/`staleSweeper`/`watchLoopLag` 现场记录/`/healthz` 连接计数/上传请求中止清理）
- 巡检：`~/Downloads/dl-healthcheck.sh` + `.service` + `.timer`
- 测试脚本：`/tmp/stale-conn-test.mjs`（僵死连接复现）、`/tmp/up-test.mjs`（功能 23 项）、`/tmp/concurrency-test.mjs`（并发合并压测）
