XMPP 连接状态与超时处理

Aug 22·7 min
AI 生成的摘要
整理商业 IM 软件的连接过程:Socket、认证、绑定、分阶段超时和重试,最后看一处清理标志互相干扰的调用。

商业 IM 软件的 Dart 客户端连上 Socket 以后,还要初始化协议流、认证、绑定资源,才能发业务消息。

所以这里的 connected 和 ready 是两个状态。前面记过重连后的历史补偿,这篇补一下连接本身的流程,以及超时、重试和清理放到一起时容易漏掉的地方。

连接状态

项目定义了下面几个连接状态:

状态在这套客户端中的含义
disconnected当前没有处于可用连接流程中
connecting正在建立网络连接
connected网络连接已建立,协议准备仍未完成
authenticating正在认证身份
authenticated认证完成,后续绑定等步骤仍在进行
ready当前连接初始化流程完成

其中,对外的 isConnected 实际按 ready 判断:

snippet
dart
bool get isConnected => _connectionState == XmppConnectionState.ready;

这里的命名容易让人误会,所以理解代码时要看具体判断,不能只看单词。

底层 sendXml 则没有要求所有发送都必须在 ready 之后。认证和绑定过程本身也要发送协议数据,发送入口与业务可用状态自然不能完全等同。

另外,定义了这些枚举,不代表代码已经自动限制了合法状态转换。当前状态更新方法主要负责赋值和广播,允许从哪里到哪里,仍然分散在具体调用流程中。

按阶段超时,才能知道卡在哪里

Token 连接路径会分别等待网络连接、服务器特性、认证响应、重新初始化流和资源绑定。

当前代码中相关等待常量如下:

等待范围配置值
网络建立10 秒
认证8 秒
协议流等待5 秒
绑定5 秒
单轮连接尝试的外围等待默认 20 秒

这些是当前实现的参数,不是适用于所有网络的推荐配置。

阶段超时方便定位失败点;外围等待则用于限制这一轮连接尝试。两者同时存在时,阶段预算之和不等于实际一定等待的时间,外围等待可能先结束。

如果日志里只写“连接超时”,网络连不上和认证阶段没拿到响应就混在一起了。记录阶段,比单纯记录异常文本更容易关联到实际处理动作。

“总超时”可能只限制了一轮

当前连接入口在重试循环内部创建超时竞争。也就是说,每次进入下一轮,都会重新获得这一轮的等待预算。

_maxTokenRetryAttempts 为 2,循环包含首次尝试,所以最多会进入三轮。默认的 20 秒不能解释为整个登录操作最多等待 20 秒。

两次重试之间还会等待退避时间。当前退避计算为:

snippet
dart
final delay = _initialRetryDelay * (1 << (attempt - 1));
return delay > Duration(seconds: 5) ? Duration(seconds: 5) : delay;

初始延迟是 800 毫秒,在这条最多两次重试的路径中,对应 800 毫秒和 1.6 秒。

如果产品需要限制整个登录动作的时间,可以在入口保存一个截止时间,每个阶段和下一次重试都使用剩余预算。这是另一种契约,不能只给循环内部的参数起名为“总超时”就认为已经实现。

超时以后,旧连接任务可能还在继续

连接流程使用 Future.any 让实际操作和超时 Future 竞争。Dart 的语义是采用最先完成的结果,其他结果不用于返回值;它没有在这个方法里替调用方取消其他任务。参见 Future.any 官方文档

因此需要考虑这样的时序:

snippet
text
第一轮连接开始
  → 外围等待超时
  → 开始清理并安排下一轮
  → 第一轮内部的异步操作迟到完成

旧结果是否还能更新 Socket、状态或者 Token,需要另外约束。

前面离线补偿文章提到过账号和任务代次。类似思路也可以用于连接尝试:每次尝试有自己的身份,在异步边界检查它是否仍然有效。

消息补偿层已经有代次校验,底层连接流程还需要单独补。

认证错误和网络错误,不应该用同一种重试方式

当前重试循环遇到 AuthenticationException 会停止;Socket、超时等错误则可能进入重试。

这能减少对明确认证失败的重复尝试。但错误分类还要看完整传播路径:当前最终收尾中,部分非认证错误又会被包装成认证异常交给等待方。

这说明“内部按错误分类”与“外部拿到准确的错误类别”也需要一起检查。否则 UI 可能无法正确区分应该重新登录,还是稍后再尝试连接。

同样,当前可重试判断中还包含异常文本匹配。它可以兼容一些没有明确类型的错误,但如果文本变化,分类结果也可能变化。长期维护时,更稳定的错误类型和阶段信息会更容易使用。

心跳与重连,需要处理同一轮生命周期

客户端的 Ping 逻辑会记录连续失败次数,成功后清零,达到阈值再进入连接丢失处理。当前阈值是三次,单次等待为 15 秒。

这是检测策略的取舍:允许短暂失败,可以减少瞬时波动带来的恢复动作;同时也意味着确认异常需要时间。这些数值不能直接相乘当成精确的断线发现时间,因为还有定时调度和重试路径。

代码里也有不止一种恢复入口,例如连接丢失后的定时尝试,以及断开处理中的退避重连。

如果要进一步收敛,我会让连接管理器统一决定是否重试,各个入口只报告发生了什么。当前不能因为某条路径有锁,就把所有恢复任务都描述为已经统一调度。

清理保护,也可能让清理被跳过

资源清理里有一个值得单独记录的细节。

_safeCleanup 先检查 _isCleaningUp,然后设为 true,再调用 _cleanup。但 _cleanup 的第一行同样是:

snippet
dart
if (_isCleaningUp) return;

顺着这条调用路径看,外层刚设好的标志,会让内层直接返回。它原本想防止重复清理,结果却可能跳过实际释放资源的步骤。

这是当前源码中的调用关系,不代表所有清理入口都失效;直接调用 _cleanup 的路径还需要分别看。

清理是否完成,也应该能观察到具体事实:定时器已取消、订阅已结束、Socket 已关闭、排队任务已经获得结束结果。仅仅把状态改成 disconnected,不能代替这些动作。

这处清理标志值得先改:入口负责挡住重复调用,内部函数专门释放资源。两个函数都判断同一个标志,反而把真正的清理挡掉了。

最后修改时间: Sep 15
cd ..