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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions