TL;DR

  • 両プラットフォームのドキュメントは、バックグラウンドのアプリをOSがネットワークから切り離すことを許しています。Appleのアーカイブのネットワークガイドには、アプリは「一時停止され、ネットワークのトラフィックを処理できなくなることがある」「既存の接続が閉じられることさえある」とあります。AndroidのDozeのページには、端末が電源に接続されず、動かされず、画面も消えたまま一定時間たつとDozeに入り、Dozeは「ネットワークアクセスを停止」して、ジョブ、同期、標準のアラームを遅らせる、とあります。
  • 一時停止や遅延の間はアプリのコードが動かないので、離れている間に接続の喪失に気づくことはできません。確実に分かるのは、アプリがフォアグラウンドに戻った瞬間です。
  • 復帰後に readyState が1(OPEN)でも、それは自分が最後に処理したイベントの時点で信じていたことにすぎません。Linuxでの代役実験(クライアントプロセスの凍結)では、サーバーの send() は成功し続け、bufferedAmount は0のままで、サーバー側には3秒、僕が決めたハートビートの期限が切れるまで異常が何も見えませんでした。
  • 事実から導いたルーチンはこうです。「アクティブになった」たびに、期限つきのアプリ層のpingを送る。応答がなければ、閉じて、再接続し、取りこぼしを取り戻す。これを4つのケースで動かし、すべて意図どおりに動きました。
  • iPhoneもAndroid端末も試していません。実験はすべて代役で、どこに影響するかは本文で書きます。

プラットフォームが「してよい」と言っていること

次のページは、書く際に元のサイトで読みました。

プラットフォームドキュメントの記述出典
iOSアプリは「バックグラウンドに入ると一時停止されることがあり、その場合はネットワークのトラフィックを処理できなくなる。場合によっては、アプリの一時停止中に既存の接続が閉じられることさえある」Apple, Platform-Specific Networking Technologies(アーカイブ)
Android, Doze端末が電源に接続されず、動かされず、画面が消えたまま一定時間たつと、システムはDozeに入ります。Dozeの間、システムは「ネットワークアクセスを停止」し、ウェイクロックを無視し、標準のアラームをメンテナンスウィンドウまで遅らせる。Android, Doze and App Standby
Android, App Standby「最近のユーザー操作がないアプリのバックグラウンドのネットワーク活動を遅らせる」同じページ
Android, 推奨「メッセージを受け取るためにネットワークへの常時接続が必要なら、可能であればFirebase Cloud Messaging(FCM)を使う」。高優先度のメッセージは、通知になるものだけに使う。同じページ

この表には注意が2つあります。Appleのページは公式のドキュメントアーカイブのもので、古いものです。同じことを1文で書いている現行のページは見つけられなかったので、最新の言い回しではなく、原則として読んでください。また、どちらのページも、切り離されるまでの猶予が何秒か、いつ起きるかという数字は書いていません。システムが「しうること」が書かれているだけです。そこが肝心で、ある長さの時間を前提にした設計は、プラットフォームが約束していないことを前提にした設計です。

React Nativeでは、AppState がJavaScriptのアプリにこの遷移を知らせる窓口です。状態は active、background、iOSでは inactive で、change イベントがあります(React Nativeのドキュメント)。起きていた間の遷移は教えてくれますが、アプリが動いていない間にソケットに何が起きたかは教えてくれません。

ドキュメントを真面目に受け取ると導かれる4つのルール

  1. タイマーではなく、遷移の時点で決める。 一時停止中や、Dozeでアラームが遅らされている間は、タイマーが期待どおりには発火しません。アプリ内のハートビートは、接続が死ぬことに気づけません。サーバーのハートビートはアプリの沈黙に気づけますが、動けるのはサーバー側だけです。クライアントで確実なのは、フォアグラウンドになるイベントの瞬間です。
  2. 復帰後に OPEN と言うソケットには、証明させる。 相手のアプリケーションが答えるメッセージを送り、期限を付けます。ブラウザ流のWebSocket APIにはpingフレームがないので、アプリ層のメッセージにします。(僕が読んだReact Nativeのソース(0.87.1)では、WebSocketに ping() メソッドはありますが、pong を受け取るイベントは見つからず、そちらでも応答を待つことはできません。)
  3. 証明できなければ、作り直して取り戻す。 閉じて、再接続し、最後に見たものより後のすべてをサーバーに聞きます。再接続だけでは、離れていた間の出来事が黙って失われます。
  4. サーバーはクライアントを見限れなければならない。 凍結したクライアントは、閉じたクライアントではありません。サーバーにハートビートの期限がなければ、誰もいない接続の状態を持ち続けます。

このリストにないものにも注意してください。バックグラウンドで生き残るための小技です。通話、音声、位置情報、ダウンロードのようにプラットフォームが認める用途には、専用の仕組みと審査ルールがあります。僕はどれも試していません。「何かが起きたとユーザーに知らせる」用途について、AndroidのページはFCMを案内しています。読んだのはAndroid側の助言だけで、Appleの現行のプッシュ通知のドキュメントは読んでいません。

サーバーから見た、凍結したクライアント

「OSがアプリを止めた」状態のLinuxでの代役は、クライアントプロセスへの SIGSTOP です。iOSやAndroidがやることそのものではありません。ここではカーネルとTCP接続は生きていて、止まるのはコードだけです。再現しているのは1点で、「アプリは何にも応答できないのに、相手側は進み続ける」ことです。実験では、ws のサーバーが500msごとにメッセージとpingを送り、3000ms pongがなければそのクライアントはいなくなったと数えます。クライアントのプロセスは接続し、2.0秒で SIGSTOP、9.0秒で SIGCONT を受けます。

// What does a server see when the client process is frozen (SIGSTOP) but its kernel and network are fine?
// Linux stand-in for "the OS suspended the app". Run: node freeze_lab.mjs
import { WebSocketServer } from "ws";
import { spawn } from "node:child_process";

const t0 = Date.now(); const at = () => `[${((Date.now() - t0) / 1000).toFixed(1).padStart(4)}s]`;
const wss = new WebSocketServer({ port: 0 });
await new Promise((r) => wss.on("listening", r));
const port = wss.address().port;

const client = spawn(process.execPath, ["-e", `
  const WebSocket = require("ws"); let n = 0;
  const ws = new WebSocket("ws://127.0.0.1:${port}");
  ws.on("message", () => n++);
  ws.on("close", (c) => { console.log("client: close event, code " + c + ", messages seen: " + n); process.exit(0); });
  ws.on("error", (e) => console.log("client: error " + e.code));
`], { stdio: "inherit", cwd: process.cwd() });

wss.on("connection", (ws) => {
  let seq = 0, lastPong = Date.now(), sent = 0, acked = 0;
  ws.on("pong", () => { lastPong = Date.now(); });
  const tick = setInterval(() => {
    ws.send(`m${++seq}`, () => acked++);               // callback = handed to the kernel
    sent++;
    ws.ping();
    const quiet = Date.now() - lastPong;
    if (seq % 2 === 0) console.log(`${at()} server: sent=${sent} handed-to-kernel=${acked} bufferedAmount=${ws.bufferedAmount} quiet-for=${quiet} ms readyState=${ws.readyState}`);
    if (quiet > 3000) {
      console.log(`${at()} server: no pong for ${quiet} ms -> declare the client gone, terminate()`);
      clearInterval(tick); ws.terminate();
    }
  }, 500);
  ws.on("close", () => console.log(`${at()} server: close event`));
});

setTimeout(() => { console.log(`${at()} >>> SIGSTOP the client process`); client.kill("SIGSTOP"); }, 2000);
setTimeout(() => { console.log(`${at()} >>> SIGCONT the client process`); client.kill("SIGCONT"); }, 9000);
setTimeout(() => process.exit(0), 11000);
[ 1.2s] server: sent=2 handed-to-kernel=1 bufferedAmount=0 quiet-for=497 ms readyState=1
[ 2.0s] >>> SIGSTOP the client process
[ 2.2s] server: sent=4 handed-to-kernel=3 bufferedAmount=0 quiet-for=501 ms readyState=1
[ 3.2s] server: sent=6 handed-to-kernel=5 bufferedAmount=0 quiet-for=1503 ms readyState=1
[ 4.2s] server: sent=8 handed-to-kernel=7 bufferedAmount=0 quiet-for=2504 ms readyState=1
[ 4.7s] server: no pong for 3004 ms -> declare the client gone, terminate()
[ 4.7s] server: close event
[ 9.0s] >>> SIGCONT the client process
client: close event, code 1006, messages seen: 9

(Node 20.19.2、ws 8.22.0、Linux 6.12、ループバック。)この結果から分かることです。

  • クライアントが凍結している間、サーバーの send() は成功し続け、bufferedAmount は0のままでした。送信側から見て異常はありません。異常を示すのは、pongが来ないことだけで、それも僕が決めた期限(3秒)と比べて初めて分かります。
  • 凍結したクライアントには何の通知もありません。SIGCONT のあと、カーネルが保持していた9件のメッセージを処理し、その後で接続が閉じられたことを、コード1006として知りました。それまでは、見に行ったコードにはソケットは OPEN に見えていたはずです。
  • 両端が同じ出来事を、違う時刻に見ます。実際の端末でその差がどれくらいかは測っていませんし、ドキュメントにも書かれていません。この差を前提に設計する必要があります。

ルーチン: 確認する、作り直す、取り戻す

実装は、ライフサイクルの供給元、接続関数、取り戻す関数を引数にしています。React Nativeに依存しないので、偽の AppState を使ってNode上でテストできました。

// On "active": do not trust readyState. Prove the socket is alive, otherwise rebuild it and catch up.
export function keepFresh({ lifecycle, connect, catchUp, probeMs = 2000, log = () => {} }) {
  let socket = null, busy = null;

  const probe = (ws) => new Promise((resolve) => {          // a round trip that the peer *application* must answer
    if (!ws || ws.readyState !== 1) return resolve(false);
    const done = (ok) => { clearTimeout(timer); ws.removeEventListener("message", onMessage); resolve(ok); };
    const onMessage = (e) => { if (e.data === "pong") done(true); };
    const timer = setTimeout(() => done(false), probeMs);
    ws.addEventListener("message", onMessage);
    try { ws.send("ping"); } catch { done(false); }
  });

  async function ensure(reason) {
    if (busy) return busy;                                   // "active" can fire repeatedly
    return (busy = (async () => {
      if (await probe(socket)) { log(`${reason}: socket proved alive, nothing to do`); return; }
      log(`${reason}: socket is ${socket ? "readyState " + socket.readyState : "missing"} or silent -> rebuild`);
      try { socket?.close(); } catch {}
      socket = await connect();
      await catchUp();                                       // whatever was missed while we were away
    })().finally(() => { busy = null; }));
  }

  lifecycle.addEventListener("change", (state) => { if (state === "active") ensure("foreground"); });
  return { start: () => ensure("start"), get socket() { return socket; } };
}

重要な点が2つあります。busy は、同時に来た起動を1回の実行で共有させます。active は繰り返し発火することがあり、ガードがなければそのたびに独立したプローブと再構築が走ります。もう1つは、プローブが OPEN でないものすべて(閉じたソケットも)に対して false を返すことで、「死んでいる」と「黙っている」を同じ関数で扱えます。

4つのケースを、ループバック上の本物の ws サーバーに対して動かします。テストを短くするため、プローブの期限は500msです。

import { WebSocketServer, WebSocket } from "ws";
import { EventEmitter } from "node:events";
import { keepFresh } from "./keep_fresh.mjs";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

const wss = new WebSocketServer({ port: 0 }); await new Promise((r) => wss.on("listening", r));
const serverSide = [];
wss.on("connection", (ws) => { serverSide.push(ws); ws.on("message", (m) => { if (m.toString() === "ping") ws.send("pong"); }); });
const url = `ws://127.0.0.1:${wss.address().port}`;

class FakeAppState extends EventEmitter { addEventListener(t, f) { this.on(t, f); } }   // same shape as React Native's AppState.addEventListener
const lifecycle = new FakeAppState();
let catchUps = 0, connects = 0;
const t0 = Date.now(); const log = (m) => console.log(`[${String(Date.now() - t0).padStart(5)} ms] ${m}`);
const app = keepFresh({
  lifecycle, log, probeMs: 500,
  connect: async () => { connects++; const c = new WebSocket(url); await new Promise((r) => c.on("open", r)); c.on("error", () => {}); return c; },
  catchUp: async () => { catchUps++; },
});
await app.start(); await sleep(100);
log(`connects=${connects} catchUps=${catchUps}`);

log("--- case 1: app goes background and comes back; connection is fine");
lifecycle.emit("change", "background"); await sleep(100); lifecycle.emit("change", "active"); await sleep(300);
log(`connects=${connects} catchUps=${catchUps}`);

log("--- case 2: the connection silently died while we were away (server stops reading, so no pong)");
serverSide.at(-1)._socket.pause();
log(`client still thinks: readyState=${app.socket.readyState} (1 = OPEN)`);
lifecycle.emit("change", "active"); await sleep(900);
log(`connects=${connects} catchUps=${catchUps}`);

log("--- case 3: the server closed it while we were away");
serverSide.at(-1).terminate(); await sleep(100);
lifecycle.emit("change", "active"); await sleep(300);
log(`connects=${connects} catchUps=${catchUps}`);

log("--- case 4: three 'active' events in a row");
lifecycle.emit("change", "active"); lifecycle.emit("change", "active"); lifecycle.emit("change", "active"); await sleep(300);
log(`connects=${connects} catchUps=${catchUps}`);
process.exit(0);
[    0 ms] start: socket is missing or silent -> rebuild
[  113 ms] connects=1 catchUps=1
[  113 ms] --- case 1: app goes background and comes back; connection is fine
[  217 ms] foreground: socket proved alive, nothing to do
[  516 ms] connects=1 catchUps=1
[  516 ms] --- case 2: the connection silently died while we were away (server stops reading, so no pong)
[  517 ms] client still thinks: readyState=1 (1 = OPEN)
[ 1017 ms] foreground: socket is readyState 1 or silent -> rebuild
[ 1418 ms] connects=2 catchUps=2
[ 1419 ms] --- case 3: the server closed it while we were away
[ 1520 ms] foreground: socket is readyState 3 or silent -> rebuild
[ 1821 ms] connects=3 catchUps=3
[ 1822 ms] --- case 4: three 'active' events in a row
[ 1822 ms] foreground: socket proved alive, nothing to do
[ 2122 ms] connects=3 catchUps=3

面白いのはケース2です。クライアントは readyState=1 と言い続けますが、プローブが500msで期限切れになり、ルーチンは再構築して取り戻し処理を走らせました。readyState を見るだけの確認は、これをすり抜けます。ケース4では、3回の active が1回のプローブになり、余計な再接続は起きませんでした。

テストが「黙ったサーバー」を作る方法は、ソケットの読み取りを止めることで、これは実験用の細工です。実際の無音の死は、ネットワークの切り替え、NATのタイムアウト、プロセスの一時停止などで、どれも再現していません。

サーバーでやること

サーバーのハートビートは別の仕事で、リソースを解放し、在席を判定することです。ws のようなライブラリはpingフレームと terminate() を提供します(上で使ったとおりです)。凍結実験のサーバーは、「3秒pongがなければ」という規則で凍結クライアントを刈り取りました。期限はトレードオフです。短ければ状態を早く解放できますが、通信が悪いだけのクライアントも追い出します。長ければ、死んだクライアントの状態を持ち続けます。誤って追い出したときの痛みの大きさから決め、実際にどれくらい起きるかを測ってください。

「ユーザーに知らせる」用途は、ソケットではなくプッシュを使います。サーバーがプッシュを送り、アプリがソケットを開いて取り戻す。Androidのドキュメントはこれを勧めていて、FCMの高優先度メッセージを使えば、Dozeの間でもアプリに一時的なネットワークアクセスが与えられる、とあります。Apple側は同等の記述を読んでいないので、iOSについては何も言いません。どちらにしても、上のフォアグラウンドのルーチンが結局必要になることは変わりません。プッシュは合図であって、データではありません。

このルーチンが向く場面

状況このルーチンを使うか
アプリがフォアグラウンドにいる間だけ意味のあるライブデータ(チャット画面、ダッシュボード、共同編集の文書)使います。このために作ったものです。
アプリがバックグラウンドにいる間のイベントも知る必要がある使いません。プラットフォームのプッシュで起こす、または通知し、復帰したらこのルーチンを走らせます。
メディアのストリーミング、通話、ナビゲーション使いません。それぞれ文書化されたバックグラウンドモードがあります。試していません。
デスクトップのブラウザの普通のWebページ部分的に。ページの可視性やネットワークのイベントが違い、試していません。

ルーチンが破綻するところ

  • 復帰後に readyState を信じる。 症状: UIは「接続中」なのに、誰かが引っ張って更新するまで何も届かない。上のケース2で再現しました。直し方: 期限つきのプローブです。
  • 期限のないプローブ。 症状: 復帰後にアプリがずっと待つ。ここのプローブは、probeMs のあとに必ず false で終わります。
  • 取り戻さない再構築。 症状: エラーはないのに、離れていた間のメッセージがない。直し方: 最後に見たidや連番に基づく取り戻しの手順です。サーバー側にそのためのAPIが要ります。
  • イベントごとにプローブ。 症状: 不安定な遷移で再接続が連発する。直し方: busy のガードで、ケース4で確認しました。
  • プロトコルのpingでプローブする。 症状: socket.ping() を探しても存在しない。ブラウザにはpingメソッドがなく、React Nativeの ping()(読んだ0.87.1のソース)も対になる pong のイベントがないので、プローブはサーバーが答えるアプリ層のメッセージにします。
  • 全員が同時に再接続する。 サーバーの再起動やネットワークの事象で多数のクライアントが一斉に切れると、全員が同じ瞬間に再試行します。再接続の待ち時間にランダムなジッターを足します。大規模では試していません。
  • 見限らないサーバー。 症状: とっくにいないクライアントのためにメモリを持ち続ける。直し方: ハートビートの期限で、凍結実験のとおりです。

試してみる

  1. mkdir lab && cd lab && npm init -y && npm i ws@8 を実行し、3つのスクリプトを保存します。
  2. node freeze_lab.mjs を実行します。成功なら、SIGSTOP のあとも bufferedAmount=0 の行が続き、約3秒後に no pong の行が出て、クライアントが1006を表示するのは SIGCONT のあとです。
  3. node test.mjs を実行します。成功なら、最後が connects=3 catchUps=3 で、ケース4の「proved alive」が1行だけです。
  4. test.mjs の probeMs を50に変え、遅いマシンでケース1がどうなるかを見ます。自分のネットワークのラウンドトリップを測ってから、適切な値を決めてください。
  5. 自分のアプリでは、偽の AppState を react-native の本物に、connect を自分のソケットの生成関数に置き換えます。その組み合わせは、僕は動かしていません。

実機では何も確認していないこと

確認できたこと: 3つのスクリプトを1台のマシン(Linux 6.12、Node 20.19.2、ws 8.22.0)で動かし、示した出力を得ました。AppleとAndroidのページからの引用と、AppState の状態名は、書く際に元のページで読みました。

確認できていないこと: iPhoneやAndroidの実機、エミュレーター、React Nativeのランタイム。アプリが一時停止やDozeに入るまでの実際の猶予、その時点でOSがTCP接続をどうするか、特定のバージョンと端末でソケットが開いたままか閉じられるか、バックグラウンドモード(VoIP、音声、位置情報)、プッシュの配信と遅延、実機での AppState イベントの順序、本物の active の連発。SIGSTOP の代役はコードを凍結してもネットワークは生かしたままで、プラットフォームがやることと同じとは限りません。サーバーの3秒とプローブの500msは、実験用の値です。

離れていたあとのアプリは

アプリがしばらく離れていたあとの開いたソケットは、主張です。期限つきで質問し、答えなければ新しく作り、サーバーに取りこぼしを聞きます。