macOS 的 49.7 天定时炸弹:XNU 内核 TCP 时间戳溢出导致 TIME_WAIT 连接永不过期

· 20 min read

macOS 的 49.7 天定时炸弹:XNU 内核 TCP 时间戳溢出导致 TIME_WAIT 连接永不过期

每台 Mac 都藏着一个隐形的保质期。系统连续运行 49 天 17 小时 2 分 47 秒 后,XNU 内核中一个 32 位无符号整数溢出会让 TCP 时间戳时钟彻底冻结。时钟一停,TIME_WAIT 连接就永远不会过期,临时端口被一点点吃光,最终所有新的 TCP 连接都无法建立。ping 还是通的,其他一切都死了。大多数人知道的唯一修复方式是重启。我们在自己的 iMessage 服务监控集群上发现了这个 bug,在两台机器上做了现场复现,并在 XNU 内核源码中定位到了根因——一行看似无害的大小比较。以下是完整的故事。

背景知识

在进入正题之前,先快速过一遍几个关键概念。如果你已经熟悉 TIME_WAIT、MSL 和整数溢出,可以直接跳到「缘起」部分。

什么是 TIME_WAIT?

TCP 连接关闭后不会立刻消失。主动发起关闭的一方会进入一个叫 TIME_WAIT 的状态——连接已经不传数据了,但操作系统会把它保留一小段时间。

为什么要多此一举?两个原因:

  • 防止迟到的包搞乱新连接。 网络不保证包的到达顺序。旧连接的某个包可能还在路由器间辗转。如果操作系统立刻复用了同一个源端口和目标地址建新连接,那个迟到的包就可能被当作新连接的数据,导致数据损坏。
  • 保证关闭过程可靠完成。 TCP 的四次挥手以主动关闭方发出的最后一个 ACK 结尾。如果这个 ACK 丢了,对端会重发 FIN。TIME_WAIT 状态让连接多活一会儿,就是为了能兜住这种重传。

TIME_WAIT 的持续时间定义为 2 × MSL。到期后,操作系统就回收这条连接占用的资源——包括它占着的那个临时端口。

什么是 MSL?

MSL(Maximum Segment Lifetime,最大报文生存时间)是一个 TCP 报文在网络中存活的最长时间。RFC 793——1981 年发布的 TCP 原始规范——将 MSL 设为 2 分钟,于是 TIME_WAIT = 4 分钟。

实际上,现代操作系统用的值短得多:

操作系统MSLTIME_WAIT 时长(2×MSL)
Linux30 秒60 秒
macOS / XNU15 秒30 秒
Windows120 秒(默认)240 秒

在 macOS 上,一条关闭的 TCP 连接只需要在 TIME_WAIT 里待 30 秒就会被清理。很快——除非清理机制本身坏了。

什么是 32 位无符号整数回绕?

C 语言中的 uint32_t 能存 0 到 4,294,967,295(2³² − 1)的值。当你试图存一个超过上限的数时,它会绕回零重新开始——就像汽车里程表从 999,999 翻到 000,000。

这不是崩溃,也不是报错。对无符号整数来说,这是 C 语言标准定义的正常行为。危险在于:当代码假定计数器只会往上走、完全没考虑翻转的可能时,事情就会悄悄出错。

这类 bug 有一串经典前辈:

  • Windows 95/98 的 49.7 天崩溃——内核的 32 位毫秒 tick 计数器溢出,内部组件没处理回绕,系统直接挂起
  • Unix 2038 年问题(Y2K38)——用 32 位有符号整数存储「1970 年以来的秒数」的 Unix 系统,将在 2038 年 1 月 19 日溢出
  • GPS 周数翻转——GPS 用 10 位周数计数器,每 1024 周(约 19.7 年)溢出一次,导致部分接收器报告错误日期
  • 吃豆人的第 256 关死屏——一个 8 位整数溢出让游戏在第 255 关之后无法继续

我们在 macOS 上发现的 bug 属于同一家族。XNU 内核用一个 uint32_t 以毫秒为单位记录 TCP 时间戳。2³² 毫秒 = 49 天 17 小时 2 分 47.296 秒。之后,计数器翻回零。接下来发生的事,就是本文的主题。

缘起

我们搭了一套 iMessage 服务健康监控系统:数台 Mac 通过 Tailscale 组网,跑着多个 iMessage 服务,由一台中控机持续发送 ping/pong 消息并测量往返延迟。这些机器 7×24 小时不间断运行,除非遇到必须重启的问题,否则不会主动重启。

2026 年 3 月 30 日——距上一轮重启恰好 49.7 天——集群中几台机器悄无声息地无法建立新的 TCP 连接了。ping 照样通,已有连接照样活着,但任何需要新 TCP 套接字的操作全部失败。症状指向性很强:XNU 内核的 TCP 时间戳计数器溢出了,一个单调性保护阻止了它在溢出后继续更新。内核的 TCP 时钟冻住了,TIME_WAIT 连接不再过期,临时端口无法回收、持续堆积。唯一的恢复手段是重启。

重启受影响的机器恢复服务后,我们检查了集群里的其他机器,发现还有几台会在 4 月 1 日触达同样的阈值。

我们决定做一次现场实验。

一、发现:精确到秒的倒计时

先看机器 A 和机器 B 的开机时间:

$ sysctl kern.boottime

机器A: { sec = 1770762587 } Tue Feb 10 14:29:47 2026
机器B: { sec = 1770762608 } Tue Feb 10 14:30:08 2026

两台机器几乎同时启动,已经运行了 49 天 16 小时

而 2³² 毫秒 = 4,294,967,296 ms = 49 天 17 小时 2 分 47.296 秒

精确计算溢出时间点:

机器A 溢出: 2026-04-01 08:32:34 PDT  (剩余 ~36 分钟)
机器B 溢出: 2026-04-01 08:32:55 PDT  (剩余 ~36 分钟)

只剩半个多小时。够了。

二、实验设计:在溢出窗口制造 TCP 连接

假设很直接:如果 49.7 天溢出真的会破坏 TIME_WAIT 的垃圾回收,那在溢出前后制造一批短连接,就应该能观察到清晰的行为断裂——

  • 溢出前:TIME_WAIT 连接在 ~30 秒后正常过期
  • 溢出后:TIME_WAIT 连接卡死,永不过期

我们写了一个测试脚本,分三个阶段:

  1. 监控阶段(溢出前 35 分钟 ~ 溢出前 5 分钟):每 10 秒记录一次 TIME_WAIT 数量,不主动创建连接
  2. 制造连接阶段(溢出前 5 分钟 ~ 溢出后 5 分钟):每 2 秒向 8.8.8.8:4431.1.1.1:443 等公网地址发起约 15 个短连接(TLS 握手后立即关闭)
  3. 观察阶段:停止创建连接,继续监控 TIME_WAIT 数量

脚本在 07:58 同时部署到两台机器并启动。

三、实验结果

3.1 溢出前:TIME_WAIT 正常回收

监控阶段,两台机器的 TIME_WAIT 数据完全健康:

[07:58:09] PHASE=wait | remain=2065s | TIME_WAIT=0  | ESTABLISHED=35
[07:58:39] PHASE=wait | remain=2035s | TIME_WAIT=5  | ESTABLISHED=38
[07:59:09] PHASE=wait | remain=2005s | TIME_WAIT=2  | ESTABLISHED=36
[07:59:19] PHASE=wait | remain=1995s | TIME_WAIT=0  | ESTABLISHED=36
...
[08:27:28] PHASE=wait | remain=306s  | TIME_WAIT=0  | ESTABLISHED=41

系统自身产生的少量 TIME_WAIT 连接(0–13 个)在数十秒内全部正常过期归零。这就是正常行为。

3.2 溢出前 5 分钟:动态平衡

08:27:38,脚本进入连接制造阶段。前 30 秒 TIME_WAIT 从 0 一路爬到约 200,然后就稳住了:

[08:27:38] PHASE=blast | remain=296s | TIME_WAIT=5     ← 开始制造
[08:27:44] PHASE=blast | remain=290s | TIME_WAIT=48
[08:27:51] PHASE=blast | remain=283s | TIME_WAIT=90
[08:27:59] PHASE=blast | remain=275s | TIME_WAIT=146
[08:28:08] PHASE=blast | remain=266s | TIME_WAIT=197   ← ~30秒达到稳态
[08:28:37] PHASE=blast | remain=237s | TIME_WAIT=196
[08:29:37] PHASE=blast | remain=177s | TIME_WAIT=200
[08:30:37] PHASE=blast | remain=117s | TIME_WAIT=198
[08:31:37] PHASE=blast | remain=57s  | TIME_WAIT=192

脚本每 2 秒创建约 15 个连接(~450 个/分钟),但每个 TIME_WAIT 只活 30 秒就被回收。~30 秒后系统进入动态平衡:TIME_WAIT 稳定在约 200 个(理论值 ≈ 7.5 个/秒 × 30 秒 = 225,实测略低因为部分连接没成功)。创建和回收同步进行,不会无限累积。这就是溢出前的健康状态。

3.3 溢出时刻

[08:32:30] PHASE=blast | remain=4s   | TIME_WAIT=368
[08:32:32] PHASE=blast | remain=2s   | TIME_WAIT=383
[08:32:34] PHASE=blast | remain=0s   | TIME_WAIT=399
[08:32:36] PHASE=blast | remain=-2s  | TIME_WAIT=412
[08:32:39] PHASE=blast | remain=-5s  | TIME_WAIT=428

脚本使用壁钟时间(date +%s)估算溢出倒计时(remain=0),但内核的 microuptime() 是单调时钟,两者在 49.7 天内会累积数十秒的漂移。从完整日志看,TIME_WAIT 实际上从 remain≈28s(~08:32:06)就开始只增不减了——那才是回收真正停止的时刻。连接还是同样的速率在创建,但一个都没被回收过。

3.4 溢出后:TIME_WAIT 只增不减

机器 A 的脚本在溢出后约 50 秒就停了,机器 B 在溢出后 5 分钟才停。两台机器的监控一直持续到手动终止。

机器 B 的关键数据(脚本于 08:37:55 停止创建连接):

时间距脚本停止TIME_WAIT备注
08:37:550s2,828脚本结束
08:39:19+84s2,837应已全部过期,实际反而增加
08:40:46+171s2,852近 3 分钟后,仍在增长

这是决定性的证据。

macOS 的 TIME_WAIT 超时为 2×MSL = 30 秒。脚本停止后 84 秒,2828 个 TIME_WAIT 连接应该已经 全部过期清零。但实际上一个都没回收,反而还在微增——系统自身的正常连接也开始卡住了。

机器 A(脚本已停止创建连接,08:50 手动检查):

时间点TIME_WAIT
溢出前 (08:27)0
溢出时 (08:32:34)399
溢出后 50s (08:33:23)723
溢出后 18min (08:50:24)871

单调递增。没有任何回落。

3.5 对照:溢出前 vs 溢出后

溢出前(正常):
  TIME_WAIT 出现 → ~30秒后过期 → 回到 0
  观测: 0, 5, 7, 2, 0, 0, 3, 3, 0, 0 ...(始终在低位波动)

溢出后(异常):
  TIME_WAIT 出现 → 永不过期 → 持续累积
  观测: 399, 412, 428, 443, 458, 473, 487, 502 ...(单调递增)

四、根因分析:XNU 内核中 tcp_now 的 32 位溢出

4.1 这个 bug 的分类

这属于 32 位无符号整数定时器回绕(32-bit unsigned integer timer wraparound) 问题,在 TCP 子系统中具体表现为 TCP 时间戳计数器溢出(TCP timestamp counter overflow)。问题的核心是 tcp_now——XNU 内核的 TCP 内部时钟。它一旦停止跳动,TCP 协议栈中所有依赖它的定时器全部失效。

4.2 tcp_now:一个注定会溢出的计数器

在 XNU 内核中(Apple 开源项目 apple-oss-distributions/xnu),tcp_now 定义于 bsd/netinet/tcp_var.h

extern uint32_t tcp_now;               /* for RFC 1323 timestamps */
#define TCP_RETRANSHZ   1000           /* granularity of TCP timestamps, 1ms */

一个 32 位无符号整数,以 毫秒 为粒度记录系统运行时间。每次 TCP 需要获取当前时间戳时,调用 calculate_tcp_clock()(位于 TCP 子系统源码中,以下代码基于 XNU 内核分析):

void calculate_tcp_clock(void)
{
    uint32_t current_tcp_now;
    struct timeval now;

    microuptime(&now);
    current_tcp_now = (uint32_t)now.tv_sec * 1000 + now.tv_usec / TCP_RETRANSHZ_TO_USEC;

    uint32_t tmp = os_atomic_load(&tcp_now, relaxed);
    if (tmp < current_tcp_now) {
        os_atomic_cmpxchg(&tcp_now, tmp, current_tcp_now, relaxed);
    }
}

关键点: (uint32_t)now.tv_sec * 1000 这个计算在系统运行 4,294,967 秒后会超过 uint32_t 的最大值 4,294,967,295,发生无符号整数回绕——值从接近最大值直接跳到接近 0。

4.3 为什么溢出后 tcp_now 会冻结

问题出在 calculate_tcp_clock() 里这个守卫:

if (tmp < current_tcp_now) {
    os_atomic_cmpxchg(&tcp_now, tmp, current_tcp_now, relaxed);
}

代码的意图很直白:“tcp_now 只能单调递增”。正常情况下没毛病。但溢出时刻:

溢出前: tmp = 4,294,960,000 (接近 uint32 最大值)
溢出后: current_tcp_now = 5,000 (回绕到接近 0)

判断: 4,294,960,000 < 5,000 ?  → false!

tmp(旧值,接近最大值)大于 current_tcp_now(新值,回绕后接近 0),cmpxchg 不执行tcp_now 被锁死在溢出前的最后一个值,从此不再更新。

内核的 TCP 时钟,在这一刻 停了

4.4 TIME_WAIT 过期检查如何失败

TCP 连接进入 TIME_WAIT 状态时,内核会记录过期时间。在 bsd/netinet/tcp_timer.cadd_to_time_wait_locked() 中:

static void add_to_time_wait_locked(struct tcpcb *tp, uint32_t delay)
{
    uint32_t timer = tcp_now + delay;    // 绝对过期时间
    tp->t_timer[TCPT_2MSL] = timer;
    TAILQ_INSERT_TAIL(&tcp_tw_tailq, tp, t_twentry);
}

其中 delay = 2 * TCPTV_MSL = 2 * 15000 = 30000 毫秒。

内核的垃圾回收器 tcp_gc() 定期扫描 TIME_WAIT 队列:

TAILQ_FOREACH_SAFE(tw_tp, &tcp_tw_tailq, t_twentry, tw_ntp) {
    if (TSTMP_GEQ(tcp_now, tw_tp->t_timer[TCPT_2MSL])) {
        tcp_close(tw_tp);    // 过期了,回收
    }
}

TSTMP_GEQ 定义在 bsd/netinet/tcp_seq.h

#define TSTMP_GEQ(a, b)  ((int)((a)-(b)) >= 0)

这是标准的 有符号模运算比较,用于处理序列号回绕。正常情况下(tcp_now 持续递增),当 tcp_now >= timer 时返回 true,连接被回收。

tcp_now 冻结之后:

tcp_now   = 4,294,960,000  (冻结在溢出前的值)
timer     = 4,294,960,000 + 30,000 = 4,294,990,000
                                     (超出 uint32 最大值,发生回绕)

TSTMP_GEQ(4294960000, 4294990000)
= (int)(4294960000 - 4294990000)
= (int)(-30000)
= -30000 >= 0 ?  → false!

永远 false。连接永远不会被回收。

4.5 完整因果链

系统运行 49 天 17 小时 2 分 47 秒

microuptime() 返回的毫秒值超过 2^32

(uint32_t) 强制转换导致值回绕到接近 0

calculate_tcp_clock() 中 if (tmp < current_tcp_now) 判断为 false

tcp_now 不再被更新,冻结在溢出前的最后一个值

此后新建的 TIME_WAIT 连接的过期检查 TSTMP_GEQ(tcp_now, timer) 永远为 false

TIME_WAIT 连接永不过期,持续累积

可用临时端口逐渐耗尽

新的 TCP 连接无法建立(SYN_SENT → 失败)

应用层出现连接超时、服务不可用

五、级联失效:从时钟冻结到 TCP 全面瘫痪

这个 bug 最危险的地方在于,它不会闹出动静。没有 kernel panic,没有错误日志,没有崩溃报告。系统看起来一切正常——直到 TCP 死掉。

以下是恶化的时间线:

溢出后几分钟:TIME_WAIT 连接停止过期。如果你的业务只有少量短连接,可能几个小时都察觉不到。

溢出后几小时:TIME_WAIT 累积到数千个。macOS 的临时端口范围通常是 49152–65535,共约 16,384 个端口,开始被蚕食。

端口耗尽:新的出站连接拿不到本地端口,卡在 SYN_SENT 然后失败。已有的长连接(ESTABLISHED)不受影响,因为它们早就绑好了端口。

系统负载飙升:内核花越来越多的 CPU 扫描一个永远不会缩小的 TIME_WAIT 队列。应用层不断重试失败的连接,雪上加霜。

TCP 死了。 只有 ICMP(ping)还能用,因为它根本不走 TCP 端口和定时器子系统。

唯一的恢复手段是重启——而重启只是把 49.7 天的倒计时重新归零。

六、补充证据

6.1 RFC 7323 的相关规定

RFC 7323(TCP Extensions for High Performance)Section 5.4(Timestamp Clock)指出,以 1ms 为粒度的 32 位时间戳,其 sign bit 在约 24.8 天后回绕(2^31 ms);Section 5.5(Outdated Timestamps)则要求 PAWS 实现在连接空闲超过 24 天时 invalidate 缓存的时间戳。

而我们观测到的溢出周期是 49.7 天——这是 32 位无符号整数的全量回绕(2^32 ms),恰好是 RFC 所述 sign bit 回绕周期的两倍。RFC 讨论的是远端发送的 TCP 时间戳选项的回绕,而非本机内核自身的定时器变量回绕——后者是 XNU 的实现缺陷。

6.2 社区中的一致症状报告

Apple 社区论坛和开源项目中,有多个与这个 bug 症状一致的报告:

  • Apple Community #250867747:macOS Catalina 上 “New TCP connections can not establish”——新连接进入 SYN_SENT 后直接关闭,已有连接不受影响,只有重启能修复
  • Apple Community #252991075:“Mac Pro TCP/IP stops working”——TCP 完全失败,但 ping(ICMP)正常工作
  • Podman issue #12495:“podman machine network connectivity stalls after some uptime” on macOS 12——运行在 macOS 上的 podman 虚拟机出现 TCP 出站停止、ICMP 正常的症状,运行多天后发生

这些报告有一个共同模式:TCP 失败但 ICMP 正常、只有重启能修复、运行数周后发生——跟 tcp_now 溢出的预期症状完全吻合。ICMP 不走 TCP 定时器,自然不受影响。

七、影响评估

谁会中招?

满足以下两个条件的 macOS 系统都会受影响:

  1. 持续运行超过 49 天 17 小时不重启
  2. 有 TCP 网络活动(基本上所有联网的 Mac)

普通用户的 Mac 通常会因为系统更新等原因在 49 天内重启,所以很少触发。但以下场景是高危的:

  • 长期运行的服务器集群(就像我们的场景)
  • macOS CI/CD 构建服务器(Jenkins、GitHub Actions 自托管 runner)
  • Mac Pro 工作站(长期运行的渲染、编译、仿真任务)
  • colocation 托管的 Mac(远程管理,极少重启)
  • Mac mini 集群(用作构建农场或测试基础设施)

八、实验复现指南

想在自己的 macOS 机器上验证这个 bug?四步搞定。

8.1 计算溢出时间

boot_sec=$(sysctl kern.boottime | grep -o 'sec = [0-9]*' | head -1 | awk '{print $3}')
now_sec=$(date +%s)
remain=$(( 4294967 - (now_sec - boot_sec) ))
echo "距溢出: $((remain/3600))h $((remain%3600/60))m $((remain%60))s"

8.2 在溢出前后监控 TIME_WAIT

while true; do
    tw=$(netstat -an | grep -c TIME_WAIT)
    echo "$(date) TIME_WAIT=$tw"
    sleep 5
done

8.3 在溢出窗口制造连接

for i in $(seq 1 10); do
    curl -s -o /dev/null --connect-timeout 2 --max-time 2 "https://1.1.1.1" &
done

8.4 观察溢出后 TIME_WAIT 是否过期

停止制造连接后等待 2 分钟,如果 TIME_WAIT 数量没有下降,bug 已复现。

九、后续观测:溢出 9.5 小时后的系统状态

我们没有在溢出后立即重启,而是让两台机器继续跑,观察 bug 的自然演进。

溢出后 9.5 小时(18:02 PDT)

机器A (uptime: 50 days, 2:33):
  TIME_WAIT:   4,888
  SYN_SENT:    3,044
  ESTABLISHED:    37
  FIN_WAIT_1:      9
  LAST_ACK:        3
  Load:          1.62

机器B (uptime: 50 days, 2:33):
  TIME_WAIT:   8,217
  SYN_SENT:    3,315
  ESTABLISHED:    38
  FIN_WAIT_1:      9
  LAST_ACK:       23
  CLOSING:          2
  Load:         49.74

TIME_WAIT 累积曲线

时间距溢出机器A TIME_WAIT机器B TIME_WAIT
08:320 min399801
08:37+5 min~723(脚本已停止)2,828
08:50+18 min8712,939
18:02+9.5 h4,8888,217

没有一条 TIME_WAIT 连接被回收过。只增不减,斜率恒正。

SYN_SENT 堆积——新连接开始失败

溢出后 9.5 小时,两台机器分别出现 3,0443,315 个 SYN_SENT 状态的连接——TCP 端口耗尽的典型信号:

  • 大量出站连接卡在 SYN_SENT(三次握手的第一步),根本完成不了握手
  • 可用临时端口被 TIME_WAIT 占满,新连接竞争不到端口

而 ESTABLISHED 连接仅剩 37–38 个——已建立的长连接还能用,但几乎不可能建立任何新连接了。机器 B 的系统负载飙到 49.74,内核在不停扫描那个永远不会缩小的 TIME_WAIT 队列。

症状与预测完全吻合

这正是第四节(4.5)因果链和第五节级联失效所预测的完整演进:

TIME_WAIT 卡死(已确认)
  → 端口逐渐耗尽(TIME_WAIT: 4,888–8,217)
    → SYN_SENT 堆积(3,000+,新连接失败)
      → 系统负载飙升(49.74)
        → TCP 实质瘫痪,仅 ICMP 可用
          → 唯一恢复手段:重启

结语

一个 32 位整数,一个看似无害的 if (tmp < current_tcp_now) 守卫,49.7 天的耐心等待。就这三样东西,构成了一枚定时炸弹。

这类 bug 的阴险之处在于,它能穿透每一道防线。开发测试抓不到它——谁会跑 50 天的测试?代码审查发现不了它——逻辑看起来完全合理。在生产环境中它甚至可能被误诊为网络问题或硬件故障。只有当你恰好盯着一台运行了 49 天的机器,并且恰好知道 2³² 毫秒等于 49.7 天时,拼图才会合上。

我们在集群的多台服务器上真实复现了这个问题。证据确凿:溢出前 TIME_WAIT 正常过期(0–13 个),溢出后 TIME_WAIT 永不回收(累积到数千个)。tcp_now 冻结了,内核的 TCP 时钟停了,而系统的其他一切看起来都很正常——直到端口耗尽的那一刻。

如果你管理着长期运行的 macOS 服务器,记住这个数字:49 天 17 小时 2 分 47 秒。我们正在研发一个比重启更好的方案——一个针对性的 workaround,能在不重启系统的前提下解冻 tcp_now。在那之前,请在倒计时归零前安排好重启。

# macOS# 技术教程# XNU# TCP# 内核# 踩坑记录