0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

socket.connect() はバックオフ待ちの間だけ何もしない:socket.io の再接続を外から叩き起こす

0
Posted at

結論から書きます。socket.io-client の socket.connect() は、ライブラリ自身が再接続のバックオフを待っている間は何もしません。 例外も警告も出ず、接続も始まりません。呼び出しは普通に返ります。

リアルタイム音声を送るアプリで、デプロイのたびに接続が切れる問題を追っていました。切断中に届くはずだった音声フレームは捨てているので、切れている時間がそのまま「聞こえなかった時間」になります。ライブラリの再接続は待ち時間を挟むので、そこを飛ばしてすぐ繋ぎ直したい。そこで自前の処理から socket.connect() を呼んでいました。

何しろ呼び出しは成功します。型も通り、ログも静かです。実際には何も起きていないと気づいたのは、しばらく経ってからでした。

計測は socket.io / socket.io-client 4.8.3、Node.js v24.21.0 で行いました。

バックオフ待ちの再接続を外から叩き起こせるか

何が起きていたか

サーバ側から下の接続を閉じたとき、クライアントの disconnect ハンドラが受け取る reason は transport close です。この時点でライブラリはすでに次の再接続をスケジュールしていて、待ち時間の間は manager._reconnecting が true になっています。

socket.on("disconnect", (reason) => {
  console.log(reason); // "transport close"
  console.log(socket.io._reconnecting); // true
});

この待ち時間を飛ばしたい、というのがやりたかったことです。

ソースを読む

socket.io-client 4.8.3 の build/cjs/socket.js にある connect() の本体です。

connect() {
    if (this.connected)
        return this;
    this.subEvents();
    if (!this.io["_reconnecting"])
        this.io.open(); // ensure open
    if ("open" === this.io._readyState)
        this.onopen();
    return this;
}

this.io.open() を呼ぶ条件が !this.io["_reconnecting"] です。バックオフ待ちの最中は、まさにこのフラグが true なので open() は呼ばれません。 subEvents() も、すでに購読が張られていれば即 return します。

subEvents() {
    if (this.subs)
        return;
    // ...
}

続く if ("open" === this.io._readyState) も、切断後は _readyState が "closed" なので成立しません。結果としてこの呼び出しは、何もせず this を返すだけになります。

Manager.reconnect() 側も見ておきます。

reconnect() {
    if (this._reconnecting || this.skipReconnect)
        return this;
    // ...
    const delay = this.backoff.duration();
    this._reconnecting = true; // ← タイマーを張ると同時に立てる
    const timer = this.setTimeoutFn(() => {
        // ...
        self.open((err) => {
            if (err) {
                self._reconnecting = false; // 失敗したときだけ下ろす
                self.reconnect();
            }
            // ...
        });
    }, delay);

_reconnecting が下りるのは「試行してエラーになった直後」だけで、続く reconnect() がすぐまた立て直します。ライブラリが再接続ループに入っている間は、ほぼずっと true です。そしてそこが、まさに外から手を入れたい唯一の場面でした。

実測する

読み解くより動かしたほうが早いので、サーバ側から接続を落とし、バックオフ待ちの 200ms 後に叩き起こして測ります。

// verify.cjs — npm i socket.io@4.8.3 socket.io-client@4.8.3
const { createServer } = require("http");
const { Server } = require("socket.io");
const { io } = require("socket.io-client");

const PORT = 4137;
const URL = `http://127.0.0.1:${PORT}`;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));

const http = createServer();
const srv = new Server(http, { transports: ["websocket"] });

let live = null;
let serverSockets = 0;
srv.on("connection", (s) => {
  serverSockets += 1;
  live = s;
});

// サーバ側から下の接続を閉じる(デプロイ時の切断を模す)
const dropFromServer = () =>
  new Promise((r) => {
    live.conn.once("close", r);
    live.conn.close();
  });

async function trial(label, force) {
  const s = io(URL, {
    transports: ["websocket"],
    reconnectionDelay: 1500, // 待ち時間を長くして観察しやすくする
    reconnectionDelayMax: 1500,
    randomizationFactor: 0,
  });
  const reasons = [];
  s.on("disconnect", (r) => reasons.push(r));
  await new Promise((r) => s.once("connect", r));

  await dropFromServer();
  await wait(200); // バックオフ待ちの最中

  const reconnecting = s.io._reconnecting;
  const before = serverSockets;

  const t = Date.now();
  force(s);

  // 5ms 刻みで再接続時刻を記録しつつ、700ms 時点のスナップショットも取る
  let reconnectedAt = -1;
  while (Date.now() - t < 700) {
    if (reconnectedAt < 0 && s.connected) reconnectedAt = Date.now() - t;
    await wait(5);
  }
  const after700 = s.connected;
  const socketsAt700 = serverSockets - before;

  while (!s.connected && Date.now() - t < 5000) await wait(5);
  if (reconnectedAt < 0 && s.connected) reconnectedAt = Date.now() - t;

  console.log(
    `${label}  _reconnecting=${String(reconnecting).padEnd(5)} ` +
      `700ms時点: 接続=${String(after700).padEnd(5)} 新規socket=${socketsAt700}  ` +
      `再接続まで=${reconnectedAt}ms  reasons=${JSON.stringify(reasons)}`,
  );
  s.disconnect();
  await wait(100);
}

(async () => {
  await new Promise((r) => http.listen(PORT, "127.0.0.1", r));
  await trial("connect()           ", (s) => s.connect());
  await trial("io._close()+connect()", (s) => {
    s.io._close();
    s.connect();
  });
  process.exit(0);
})();

差し替えているのは force に渡す 1 関数だけです。実行結果です(3 回まわして同じ値に収まりました)。

connect()             _reconnecting=true  700ms時点: 接続=false 新規socket=0  再接続まで=1310ms  reasons=["transport close"]
io._close()+connect()  _reconnecting=true  700ms時点: 接続=true  新規socket=1  再接続まで=6ms  reasons=["transport close","forced close"]

1 行目の socket.connect() です。700ms 経っても接続は始まらず、サーバ側の新規接続は 0 本。 「再接続まで 1310ms」は、こちらの呼び出しが繋いだのではなく、ライブラリが自分で張ったタイマー(1500ms)が満了しただけです。呼び出しは待ち時間を 1ms も縮めていません。

叩き起こすには io._close() が要る

2 行目が正解です。io._close() を挟むと 6ms で繋がります。

socket.io._close();
socket.connect();

Manager._close() はこうなっています。

_close() {
    debug("disconnect");
    this.skipReconnect = true;
    this._reconnecting = false; // ← フラグを下ろす
    this.onclose("forced close");
}

_reconnecting を下ろし、バックオフも onclose() の中の this.backoff.reset() で戻ります。この状態なら connect() の if (!this.io["_reconnecting"]) が成立し、open() が走って skipReconnect も false に戻ります。

_close() はアンダースコア付きですが、socket.io-client の manager.d.ts には _close(): void; として載っています。_reconnecting も公開フィールドです(private なのは skipReconnect のほう)。型から消える心配はありません。

副作用:disconnect がもう 1 回飛ぶ

上の reasons をもう一度見てください。io._close() を使った側だけ ["transport close", "forced close"] と 2 つ記録されています。_close() は onclose("forced close") を呼ぶので、アプリ側の disconnect ハンドラがもう一度走ります。

切断理由をそのまま送っている計測があると、この 2 つ目が本物の理由を上書きします。forced close を除外しないと、次に同じ問題が起きたときにログから原因が読めません。

socket.on("disconnect", (reason) => {
  if (reason === "forced close") return; // 自分で叩き起こした分は数えない
  reportDisconnect(reason);
});

もう 1 つの罠:自分で切ったのに勝手に繋がる

同じ調査で、socket.active の見え方にも引っかかりました。active は「この Socket が Manager に購読を張っているか」を返すゲッターです。

get active() {
    return !!this.subs;
}

disconnect() の実装は、先に購読を捨ててからイベントを飛ばします。

disconnect() {
    if (this.connected) {
        this.packet({ type: socket_io_common_1.PacketType.DISCONNECT });
    }
    this.destroy(); // ← ここで subs が消える
    if (this.connected) {
        this.onclose("io client disconnect");
    }
    return this;
}

なので disconnect ハンドラの中では、socket.active はすでに false です。「切れていたら繋ぎ直す」という自然なガードを書くとします。

socket.on("disconnect", () => {
  if (!socket.active) socket.connect();
});

最小構成で観測すると、こうなります。

handler saw        : [{"reason":"io client disconnect","activeInsideHandler":false,"subs":false}]
new server sockets : 1

自分から意図して disconnect() を呼んだのに、このガードが成立して自動で繋ぎ直り、サーバ側には新しい接続が 1 本増えています。このガードを書くなら、理由まで見る必要があります。

// 終わらせたのは自分かサーバの意思。socket.io 自身も再接続しない
const TERMINAL = ["io client disconnect", "io server disconnect"];

socket.on("disconnect", (reason) => {
  if (TERMINAL.includes(reason)) return;
  if (!socket.active) rebuildConnection();
});

io server disconnect(サーバが明示的に切った)と io client disconnect(自分で切った)は、どちらも 3 秒待って再接続 0 本になることを実測で確認しました。socket.io 自身が追わないものを自前で追うと、二重に繋ぎに行きます。

割り切ったこと

ここまでの数値は、すべて 1 台のローカル環境で測ったものです。実際にこれを入れたあと、本番ではローカルの数値どおりにはなりませんでした。「その経路を通ったときに 6ms で繋がる」ことと「あらゆる切れ方でその経路に乗る」ことは別で、ローカルの計測は前者しか証明しません。速さを測る前に、その reason が本当にハンドラに届いているかを先に確かめるべきでした。

なので今は、切断の形ごとに経路を 1 つずつ作って同じ計測を回すところまでを 1 組にしています。

reason 起きる場面 socket.io 自身の再接続
transport close 回線断・プロセス停止 あり
io server disconnect サーバが明示的に切った なし(終端)
io client disconnect 自分で disconnect() した なし(終端)
forced close io._close() を呼んだ なし(自分で繋ぎ直す)

この表を作るのに使ったコードは、筆者が関わっている面接練習AI(AceRound)の音声ストリーム経路で保守しています。切断中の音声は捨てる設計なので、再接続までの空き時間がそのまま失われる音声になります。

まとめ

  • socket.connect() は if (!this.io["_reconnecting"]) this.io.open() を通る。バックオフ待ちの最中はこのフラグが立っているので、呼んでも何も起きない
  • 叩き起こすには socket.io._close() でフラグとバックオフを戻してから connect()。実測で 1310ms → 6ms
  • _close() は disconnect をもう 1 回、reason = "forced close" で飛ばす。計測側で除外する
  • disconnect ハンドラの中では socket.active はすでに false。if (!socket.active) reconnect() だけのガードは、意図的な切断でも発火する

「メソッドを呼んだ」ことは「状態が変わった」ことの証明になりません。 今回の 3 つはどれも、呼べて、返ってきて、何も起きていないタイプの失敗でした。ライブラリが相手のときは、呼び出しのあとに状態を 1 つ読んで確かめるほうが早いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?