Skip to content

bug: 用户显式 Reconnect() 与自动重连竞速可长期楔死被动端监听(重绑失败 + 接受循环持有锁重试) #47

Closed

Description

@CSJ608

背景

v2.3.1 开发浸泡/混沌套件(PR #46)时发现:混沌注入"用户显式 Reconnect()"动作后,被动端服务端会长达 60 秒以上无法接受新连接(对端 connect 持续被拒),期间无任何 accept 失败日志、无状态机自愈。当前混沌套件刻意排除了该动作(test/StreamFrame.Tests/SoakTests.cs 的注释有记录)。

复现条件(已观察到的形态)

  • 被动模式(isActive=false)+ 默认 AcceptFirstClientOnly=true;
  • 服务端发起的拆除(用户 Reconnect(),或与自动重连形成双 StartAsync 竞速)反复发生;
  • 拆除后 InitServer 对同一端口的重新 Bind 失败(疑似 WSAEADDRINUSE:服务端主动关闭留下的 TIME_WAIT 占用本地端口;Windows 默认 TIME_WAIT 时长 60–240s)。

对照实验:纯对端死亡(客户端先关,服务端被动关闭,无 TIME_WAIT 落在监听端口)的混沌不会楔死——20s/60s 两档浸泡全部通过;加入 server.Reconnect() 动作后稳定复现。

根因分析(两层叠加,待进一步证实)

网络层:InitServer(src/StreamFrame/Connection/StreamConnection.cs)创建监听 socket 时未设置 SO_REUSEADDR——服务端主动关闭的连接在监听端口上留下 TIME_WAIT,重新绑定同端口失败。

状态机层(放大器):

  1. AcceptAsync 的重试延迟(AcceptRetryDelayMs,默认 2s)发生在持有 _acceptLock 的 try 块内部——绑定持续失败期间,锁被一直持有,后续任何接受循环都被堵在锁上,故障时长 = TIME_WAIT 存续期;
  2. 用户 Reconnect() 与自动重连(ScheduleReconnect)并发可产生双 StartAsync 接受循环(_retryInProgress 的 CAS 在 StartCore fire-and-forget 后即释放),加剧服务端主动关闭的频率与 TIME_WAIT 堆积;
  3. v2.3.0 评审的"附带发现"(Connected 发布与 StartSession 之间的瞬态窗口)同属"状态过渡未原子化"一族。

待确认(诊断步骤)

  1. 写确定性复现:循环"连接 → server.Reconnect() → 重连"×N,楔死窗口内 netstat -ano | findstr :port 确认 TIME_WAIT/绑定状态;监听 accept failed 日志(现有 Warning 日志应能看到 WSAEADDRINUSE);
  2. 若确认绑定失败:验证监听 socket 设置 ReuseAddress 后是否消除。

修复方向(建议分阶段)

  1. 阶段一(低成本高确定性):
    • 监听 socket 设置 SO_REUSEADDR(服务端标准做法,允许绑定覆盖本进程遗留的 TIME_WAIT;文档注明 Windows 上该选项的安全权衡);
    • 把 accept 重试延迟移出 _acceptLock 持有区(或改为失败后释放锁再延迟),避免重试期堵死其它接受循环。
  2. 阶段二(状态机):单一飞行重连——StartCore 携带代次(generation),接受循环每次重试前校验自己是否仍是当代,被取代的循环静默退出;用户 Reconnect() 与自动重连统一走同一条请求通道。
  3. 阶段三(可选):状态过渡原子化的整体设计(评审"附带发现"的彻底收口)。

验收标准

  • 确定性复现测试:快速服务端主动重连 × N 次后,被动端在秒级内可接受新连接;
  • 混沌套件恢复"用户 Reconnect()"动作后依然全绿;
  • 现有全部测试不回归(net8.0/net10.0/net48)。

Activity

  1. CSJ608 commented on Aug 29, 2026

    @CSJ608
    OwnerAuthor

    排查结果(PR #48)

    按本 issue 的诊断步骤完成排查,结论与预期不同:

    原始"楔死"无法复现

    1. 顺序复现:8 轮"对端接入 → 服务端显式 Reconnect()(服务端主动关闭)→ 端口须 10s 内可重新接受"——全程秒级可用,零 accept 失败日志。Windows 实测允许重绑覆盖服务端主动关闭留下的 TIME_WAIT,原 TIME_WAIT/WSAEADDRINUSE 假设在本机不成立;
    2. 并发复现:每轮同时触发"杀对端(自动重连)+ 用户 Reconnect()"的竞速形态——3× 全过;
    3. 混沌回归:把用户 Reconnect() 动作加回混沌套件,20s/30s 多轮全绿。

    回溯判定:开发浸泡套件时观察到的 60s+ 拒绝,是当时混沌测试自身三个缺陷的组合(client is null || action < 10 的双重连接泄漏占住单客户端、循环收尾未释放存活对端、变量遮蔽导致的静默编译失败使我们一度在跑旧二进制)——均已在 PR #46 修复。库的接受机制(含用户重连与自动重连竞速)经证实收敛正确。

    仍然落地的防御性加固(PR #48)

    排查中识别的两个真实弱点(即使本次未成为触发器):

    1. 接受循环的重试延迟移出 _acceptLock:旧实现整个重试 while 持锁——将来若因任何外部原因(端口被占、防火墙、平台差异)持续绑定/接受失败,重试期会堵死其它接受循环,故障期被放大为全停。现改为逐次尝试持锁;
    2. 监听 socket 设置 SO_REUSEADDR:Windows 实测宽松,但 Linux 上无此选项时重绑遇 TIME_WAIT 会 EADDRINUSE(跨平台部署经典坑,当前 ubuntu CI 未踩中属时序运气)。设置后跨平台行为一致——CI(ubuntu) 已验证。

    阶段二/三的处置

    "单一飞行重连(generation)"与"状态过渡原子化"在楔死不可复现的前提下收益只剩理论值,暂不实施。若未来真实环境再次出现类似症状:现在 accept 失败有 Warning 日志且不再持锁,按加固后的两处排查即可定位。

    新增确定性回归测试 RapidServerInitiatedReconnects_PortStaysAcceptable 常驻防回归。本 issue 将随 PR #48 合并关闭。

  2. added a commit that references this issue on Aug 29, 2026
  3. CSJ608 commented on Aug 29, 2026

    @CSJ608
    OwnerAuthor

    后续:竞速泄漏监听器 bug 已确认并修复(PR #52)

    当初"无法复现"的结论需要修正:问题真实存在,只是复现强度门槛高。

    • 复现:把用户 Reconnect() 与自动重连的并发竞速提到混沌动作的 60% 后,第一轮即稳定复现;
    • 根因:被取代的"僵尸"接受循环完成 accept 后看到 _server 已被新循环替换,跳过单客户端的监听器关闭——泄漏一个仍在监听但永无人 accept 的 socket,客户端 SYN 进入无人消费的 backlog 后 ConnectAsync 永久挂起;
    • 修复:_acceptLoopId 代次门控(本 issue 的"阶段二:单一飞行重连"方案),监听器的创建/关闭/accept 只归属当代循环;
    • 长期观察:Soak_ReconnectRacing_LongRun(竞速专用混沌 + 状态机转移合法性/编号线性化全程校验)+ 每夜 600s 双平台 soak CI(.github/workflows/soak.yml)。

    当初 PR #48 的两个防御性加固(重试出锁、SO_REUSEADDR)依然有效且必要;本条为第三处、也是唯一有可复现故障的加固点。三处齐备后,issue 的三个阶段全部落地。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions