Skip to content

@larksuite/channel:握手超时(handshake timeout)竞态导致孤儿 WebSocket 抛出未捕获异常 "WebSocket was closed before the connection was established" #197

Description

@66363331

标题

@larksuite/channel:握手超时(handshake timeout)竞态导致孤儿 WebSocket 抛出未捕获异常 "WebSocket was closed before the connection was established"

正文

涉及包@larksuite/channel(实测版本 v0.3.0,通过第三方封装 lark-channel-bridge 间接使用,但问题根源在这个包本身)

现象

在网络不太稳定的环境下,我们的常驻进程会反复抛出一个未捕获异常:

uncaughtException: WebSocket was closed before the connection was established

大约 4 周的连续运行里,日志记录到这个报错出现了 1133 次。绝大多数是无害的(被我们自己顶层的 process.on('uncaughtException') 接住、只是打日志),但在一次真实的网络抖动期间,我们在约 500 行的日志窗口内密集出现了 26 次这个报错,同时伴随大量 network.unreachable(网络不可达)和 keepalive.force-reconnect(强制重连)事件,紧接着进程就真的崩溃了(通过 launchdKeepAlive+ThrottleInterval 严格按 30 秒间隔重启,证实是真实的、反复的进程死亡,而不是良性重启)。

根因(在 dist/index.cjs 里定位到的)

connectWebSocket(timeoutMs) 大致逻辑是:

connectWebSocket(timeoutMs) {
  return new Promise((resolve, reject) => {
    let settled = false;
    const timer = setTimeout(() => {
      if (settled) return;
      settled = true;
      reject(new LarkChannelError("not_connected", `WebSocket handshake did not complete within ${timeoutMs}ms`));
    }, timeoutMs);
    this.rawWsClient = new WSClient({
      ...
      onReady: () => { if (settled) return; settled = true; clearTimeout(timer); resolve(); },
      onError: (err) => { if (settled) return; settled = true; clearTimeout(timer); reject(...); },
      ...
    });
    this.rawWsClient.start({ eventDispatcher: this.dispatcher });
  });
}

timeoutMs 定时器先触发(也就是握手在 handshakeTimeoutMs 时间内还没完成)时,外层 Promise 会 reject,但刚创建的这个 WSClient 实例(this.rawWsClient)并没有被主动关闭或中止——它会在后台继续尝试完成握手,成了一个和已经"放弃"它的代码脱钩的孤儿对象(调用方此时已经认为这次连接尝试"超时"了,可能已经通过 forceReconnect() 发起了新一轮连接)。

之后,当这个孤儿客户端底层的 ws socket 关闭时(可能是它自己的握手最终失败,也可能是 forceReconnect() 正在拆掉另一个 rawWsClient 引用、而这个旧的还残留在别处),底层 ws 库会抛出经典的 "WebSocket was closed before the connection was established" 错误。因为这个对象已经没人在监听了,Node 会把这个没人接住的 error 事件当作未捕获异常抛到进程顶层。

在网络持续不稳定的情况下,forceReconnect()/keepalive 会反复重试,每一次超时似乎都会泄漏一个这样的孤儿客户端(占用着 socket/定时器资源),密集发生时很可能会累积到资源耗尽——这和我们观察到的现象吻合:只有在这类报错密集爆发时才会崩溃,孤立的单次发生完全不会。

建议修复

timeoutMs 的定时器回调(和/或 onError)里,reject 之前先主动关闭/终止那个还在握手中的 WSClient/底层 socket,例如:

const timer = setTimeout(() => {
  if (settled) return;
  settled = true;
  this.rawWsClient?.close?.({ force: true });
  reject(new LarkChannelError("not_connected", `WebSocket handshake did not complete within ${timeoutMs}ms`));
}, timeoutMs);

或者在放弃这个底层 socket 之前,给它挂一个空的 error 监听器,这样它后续再关闭时就不会变成一个没人接住的事件。

我们目前的临时应对

写了一个通过 --require 预加载的小脚本,识别这一条已知的报错特征,如果 120 秒内密集出现 ≥5 次,就主动让进程退出,交给进程守护(launchdKeepAlive)干净地重启,而不是等着资源耗尽后在不可预测的时刻崩溃。这只是治标,真正的修复还是要在这个包里做。

如果需要一个最小复现(反复在握手中途掐断网络),我们可以配合提供,麻烦看看是否方便处理,谢谢!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions