TL;DR
- 多数のリクエストを流している接続では、リクエストの期限切れはそのリクエストだけをキャンセルします(HTTP/2なら
RST_STREAM)。実験では、1回のタイムアウトでセッション全体を閉じると、無関係な2本が失敗しました。ストリームだけをキャンセルすると、2本とも同じTCP接続で完了しました。 - この方針が成り立つのは、返信とリクエストをidで対応づけられる場合だけです。順番で対応づけていると、遅れて届いた返信が次のリクエストに渡され、前のリクエストの答えがエラーなしで返りました。WebSocketの上に作ったリクエスト/レスポンス層で再現しています。
- ストリームより下で止まっているときは、キャンセルでは助かりません。LinuxのネットワークネームスペースでTCP接続1本のサーバー→クライアント方向を100ms止めると、その接続を共有する全ストリームが約220〜320ms沈黙しました(5回ずつ、2回のセッション)。ストリームごとに接続を分けると、被害を受けた側は約130ms、もう片方は通常の10msおきのままでした。
- 問いは2つあります。「このリクエストは遅すぎるか」(ストリーム単位で答える)と、「この接続はまだ生きているか」(HTTP/2の
PINGなどの接続レベルの確認と、自分で決めた期限で答える)です。接続を閉じるのは、後者の問いに失敗したときだけです。 - 以下はすべてNodeで試せます。パケットを落とす実験にだけ、root、
ip netns、nftが要ります。
似ているが別の2つの問い
クライアントがサーバーに1本の接続を張り、その上で多数のリクエストを同時に流します。HTTP/2はそういう作りですし、WebSocketやRPCの多くも慣習として同じことをします。1つのリクエストが遅く、期限が来ました。どうするでしょうか。
選択肢は2つで、代償は大きく違います。
- 接続を閉じる。 実装は簡単で、確実にすべてを解放できます。ただし、その接続で動いていた他のリクエストもすべて死にます。次のリクエストは新しいTCP(とTLS)のハンドシェイクを払います。
- そのリクエストだけをキャンセルする。 接続は残り、他のリクエストは続きます。ただし接続が健全だと確信する必要があります。1つのリクエストの期限切れは、他のリクエストについて何も教えてくれないからです。
各分岐を、動かせるもので見ていきます。
比べる: セッションを閉じるか、ストリームをキャンセルするか
HTTP/2には、他に影響せず1つのリクエストだけを終わらせる仕組みがあります。RST_STREAM は「ストリームの即時終了を許す」もので、「ストリームのキャンセルを求めるために送られる」とされています(RFC 9113 §6.4)。送ったあとも、すでに相手が送出していたフレームを受け取る準備は必要です。無視してよいのは、ヘッダー圧縮やフロー制御のように接続の状態を変えるフレームを除いたものです(§5.4.2)。接続は生き残ることが前提の作りです。
実験ではNodeの標準 http2 モジュールを平文のTCPで使います。サーバーは GET /<ms> に、そのミリ秒だけ待って応答します。クライアントは、3000msかかるリクエストを期限500msで1本、800msかかるリクエストを余裕のある期限で2本、同時に送ります。2つの戦略の違いは1行だけです。
// One HTTP/2 connection, several requests, one of them is slow.
// What should the client close when the slow one hits its deadline: the stream, or the whole session?
import http2 from "node:http2";
const { NGHTTP2_CANCEL } = http2.constants;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const server = http2.createServer(); // cleartext HTTP/2 is enough for this
let sessions = 0; let serverLog = [];
server.on("session", (s) => { sessions++; });
server.on("stream", (stream, headers) => {
const delay = Number(headers[":path"].slice(1)); // GET /<milliseconds>
stream.on("close", () => { if (stream.rstCode) serverLog.push(`server saw RST_STREAM(code ${stream.rstCode}) for the /${delay} stream`); });
setTimeout(() => { if (!stream.destroyed) { stream.respond({ ":status": 200 }); stream.end(`done after ${delay} ms`); } }, delay);
});
await new Promise((r) => server.listen(0, r));
const url = `http://localhost:${server.address().port}`;
async function run(strategy) {
sessions = 0; serverLog = [];
const session = http2.connect(url);
session.on("error", () => {});
await new Promise((r) => session.on("connect", r));
const t0 = Date.now();
const request = (ms, deadline) => new Promise((resolve) => {
const req = session.request({ ":path": `/${ms}` });
let body = "";
const timer = setTimeout(() => {
if (strategy === "close-stream") req.close(NGHTTP2_CANCEL); // RST_STREAM(CANCEL) for this stream only
else session.destroy(); // tear down the TCP connection
resolve(`TIMEOUT after ${Date.now() - t0} ms`);
}, deadline);
req.on("data", (d) => (body += d));
req.on("error", () => {});
req.on("close", () => { clearTimeout(timer); resolve(body.startsWith("done") ? `ok (${body})` : "FAILED: closed without a response"); });
});
// one slow request (3 s) with a 500 ms deadline, two others that need 800 ms and have 2 s deadlines
const results = await Promise.all([request(3000, 500), request(800, 2000), request(800, 2000)]);
console.log(` strategy "${strategy}": slow -> ${results[0]} | other #1 -> ${results[1]} | other #2 -> ${results[2]} | TCP connections opened: ${sessions}`);
await sleep(300);
console.log(` ${serverLog.length ? serverLog.sort().join("; ") : "server saw no RST_STREAM"}`);
session.destroy(); await sleep(100);
}
console.log("timeout handling on a shared HTTP/2 connection");
await run("close-session");
await run("close-stream");
server.close();
timeout handling on a shared HTTP/2 connection
strategy "close-session": slow -> TIMEOUT after 505 ms | other #1 -> FAILED: closed without a response | other #2 -> FAILED: closed without a response | TCP connections opened: 1
server saw RST_STREAM(code 8) for the /800 stream
strategy "close-stream": slow -> TIMEOUT after 501 ms | other #1 -> ok (done after 800 ms) | other #2 -> ok (done after 800 ms) | TCP connections opened: 1
server saw RST_STREAM(code 8) for the /3000 stream
(Node 20.19.2。)close-stream の実行では、サーバーが見た RST_STREAM は遅いストリームの1つだけで、エラーコード8、つまり CANCEL でした。失われたのは遅いリクエストだけです。close-session の実行では、無関係な2本が約505msの時点で、800msの処理が終わる前に切られました。実際のクライアントなら、ここから新しい接続が要ります。(この実行のサーバーログには、2本ある /800 のうち1本ぶんの RST_STREAM の行だけが出ています。フレームは取っておらず、理由も調べていません。上の結論はクライアント側の結果だけに基づいています。)
これでコスト面は決着です。安全性の面は、クライアントが返信とリクエストをどう対応づけているかで決まります。
キャンセルが安全なのは、返信がidを持つときだけ
HTTP/2ではリクエストごとにストリームIDがあるので、対応づけは無償です。1本の接続を使う他のプロトコル(JSONを流すWebSocket、RPCチャネル、行プロトコルなど)にはそれがなく、到着順で対応づけてしまう実装がよくあります。その失敗を再現しました。
この実験のサーバーは、パイプライン型のプロトコルのように「順序どおり」に返信することもできます。Bの返信は、Bの準備ができていても、Aの返信より先には出ません。クライアント1は位置で対応づけます。クライアント2は、リクエストにも返信にも id を入れます。どちらもA(遅い、2000ms)、続けてBとC(各50ms)を期限500msで送り、最後に新しいリクエストDを送ります。
// Correlating replies on one shared connection: by position (FIFO) or by request id.
// One slow request (A), then two quick ones (B, C), 500 ms deadline each. Run: node mux_lab.mjs
import { WebSocketServer, WebSocket } from "ws";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// --- server: one handler per connection; "ordered" replies in request order (like a pipelined protocol), "tagged" replies as soon as ready
const wss = new WebSocketServer({ port: 0 });
await new Promise((r) => wss.on("listening", r));
wss.on("connection", (ws) => {
let chain = Promise.resolve();
ws.on("message", (raw) => {
const m = JSON.parse(raw);
const work = sleep(m.delay).then(() => JSON.stringify({ id: m.id, tag: m.tag }));
if (m.ordered) chain = chain.then(() => work).then((out) => ws.send(out)); // must wait for everything before it
else work.then((out) => ws.send(out));
});
});
const url = `ws://127.0.0.1:${wss.address().port}`;
const open = async () => { const c = new WebSocket(url); await new Promise((r) => c.on("open", r)); return c; };
// --- client 1: replies carry no id, the n-th reply belongs to the n-th request
async function positional() {
const c = await open(); const waiting = []; let late = 0;
c.on("message", (raw) => { const w = waiting.shift(); if (w) w.resolve(JSON.parse(raw).tag); else late++; });
const call = (tag, delay, deadline = 500) => new Promise((resolve) => {
const w = { resolve };
waiting.push(w);
c.send(JSON.stringify({ tag, delay, ordered: true }));
setTimeout(() => { const i = waiting.indexOf(w); if (i >= 0) { waiting.splice(i, 1); resolve("TIMEOUT"); } }, deadline);
});
const first = await Promise.all([call("A", 2000), call("B", 50), call("C", 50)]);
console.log("positional, first round :", first.join(" "));
const d = await call("D", 10, 3000); // a new request, while A's reply is still on its way
console.log(`positional, request D : asked for D, got "${d}"`);
await sleep(500);
c.close();
}
// --- client 2: replies carry the request id; the timeout removes exactly that id
async function tagged() {
const c = await open(); const pending = new Map(); let nextId = 1, orphans = 0;
c.on("message", (raw) => { const m = JSON.parse(raw); const w = pending.get(m.id); if (w) { pending.delete(m.id); w(m.tag); } else orphans++; });
const call = (tag, delay, deadline = 500) => new Promise((resolve) => {
const id = nextId++;
pending.set(id, resolve);
c.send(JSON.stringify({ id, tag, delay }));
setTimeout(() => { if (pending.delete(id)) resolve("TIMEOUT"); }, deadline);
});
const first = await Promise.all([call("A", 2000), call("B", 50), call("C", 50)]);
console.log("by id, first round :", first.join(" "));
const d = await call("D", 10, 3000);
console.log(`by id, request D : asked for D, got "${d}"`);
await sleep(2500);
console.log(`by id, afterwards : late replies dropped as orphans: ${orphans}, pending map size: ${pending.size}`);
c.close();
}
await positional(); await tagged(); wss.close();
positional, first round : TIMEOUT TIMEOUT TIMEOUT
positional, request D : asked for D, got "A"
by id, first round : TIMEOUT B C
by id, request D : asked for D, got "D"
by id, afterwards : late replies dropped as orphans: 1, pending map size: 0
位置対応の行には、別々の失敗が2つ隠れています。
- BとCもタイムアウトします。 サーバーはAを終えてからでないと返せないので、BとCはAの後ろで詰まりました。これはアプリケーション層のヘッドオブラインブロッキングで、RFC 9113の冒頭が、パイプライン化したHTTP/1.1は「依然として」抱えると書いている問題です(RFC 9113 §1)。idがあっても、順序を守るサーバーが速くなるわけではありません。idが可能にするのは、返信の準備ができたらすぐ返すサーバーです。idの実行のサーバーはそう動いています。
- DがAの答えを受け取りました。 タイムアウトでクライアントは待ち行の記録を消し、その後でAの遅い返信が届き、次の待ち手であるDに渡されました。間違った答えが、成功として渡されたのです。どこにもエラーは出ません。
idを使うクライアントが失ったのはAだけで、Aの返信がようやく届いたときは「持ち主のいない返信」として捨てられ、保留表は最後に空でした。この安全性のコストは、小さな表1つと次の決まりだけです。タイムアウトしたらidを消す。未知のidの返信は黙って捨てる。
プロトコルにidがなく、足せないなら、タイムアウト時に安全な対応は接続を閉じることだけです。それは接続をもっと閉じる理由ではなく、idを足す理由です。
キャンセルが効かないとき: 止まっているのがストリームより下の場合
ここまでは、接続自体は健全だと仮定していました。HTTP/2は全ストリームを1本のTCPバイト列に載せ、TCPはバイトを順序どおりに渡します。1つのパケットが失われると、それ以降のバイトは、どのストリームのものであっても待たされます。RFC 9113ははっきり書いています。「TCPのヘッドオブラインブロッキングは、このプロトコルでは解決されない」(§1)。QUICではこれを避けられます。「そのパケットにデータを持つストリームだけが再送を待ち」ますが、複数ストリームのデータが同じパケットに入っていれば、それらは全部止まります(RFC 9000 §13)。HTTP/3は試していません。
これを見るために、小さなネットワークを作りました。仮想イーサネットのペアでつないだ2つのLinuxネットワーク名前空間で、片方にサーバー、もう片方にクライアントがあります。サーバーは2つの応答(/a と /b)をストリーミングし、それぞれ10msごとに1000バイトを送ります。実行開始から約500msの時点で、nft がサーバーからクライアントの接続#1宛のパケットを100ms(DROP=100)だけ落とします。クライアントは各ストリームの最長の沈黙を記録します。「single」モードでは両ストリームが接続#1を共有します。「two」モードではそれぞれ別の接続で、損失を受けるのはストリーム a の接続だけです。
// Two long responses (/a, /b), each a 1000-byte chunk every 10 ms for 1.5 s. Plain-text HTTP/2.
import http2 from "node:http2";
const server = http2.createServer();
server.on("stream", (stream) => {
stream.respond({ ":status": 200 });
const chunk = Buffer.alloc(1000, 1); let n = 0;
const t = setInterval(() => { if (stream.destroyed || ++n > 150) { clearInterval(t); stream.end(); } else stream.write(chunk); }, 10);
});
server.listen(9200, "10.1.0.2", () => console.log("server up"));
// mode "single": both requests share one HTTP/2 connection. mode "two": one connection per request.
// Around t = 500 ms, packets from the server to connection #1 are silently dropped for 30 ms.
import http2 from "node:http2";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const mode = process.argv[2];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const run = promisify(execFile);
const nft = (...a) => run("nft", a); // async: do not block the event loop while measuring
const DROP_MS = Number(process.argv[3] ?? 30);
await nft("add", "table", "inet", "hol"); await nft("add", "chain", "inet", "hol", "in", "{ type filter hook input priority 0; }");
const s1 = http2.connect("http://10.1.0.2:9200");
const s2 = mode === "single" ? s1 : http2.connect("http://10.1.0.2:9200");
await Promise.all([s1, s2].map((s) => new Promise((r) => s.once("connect", r))));
const port1 = s1.socket.localPort; // the loss will hit this connection only
const stats = {};
function fetch(session, name) {
return new Promise((resolve) => {
const req = session.request({ ":path": `/${name}` });
const st = (stats[name] = { last: performance.now(), maxGap: 0, at: 0, bytes: 0 });
const t0 = performance.now();
req.on("data", (d) => { const now = performance.now(); const gap = now - st.last; if (gap > st.maxGap) { st.maxGap = gap; st.at = st.last - t0; } st.last = now; st.bytes += d.length; });
req.on("end", resolve); req.resume?.();
});
}
const done = Promise.all([fetch(s1, "a"), fetch(s2, "b")]);
await sleep(500);
await nft("add", "rule", "inet", "hol", "in", "ip", "saddr", "10.1.0.2", "tcp", "sport", "9200", "tcp", "dport", String(port1), "drop");
await sleep(DROP_MS);
await nft("flush", "chain", "inet", "hol", "in");
await done;
for (const [n, st] of Object.entries(stats)) console.log(` stream ${n}: longest silence ${st.maxGap.toFixed(0).padStart(4)} ms (starting ${st.at.toFixed(0)} ms in), ${st.bytes} bytes`);
s1.destroy(); s2.destroy();
#!/bin/bash
# root needed. Two namespaces joined by a veth pair; server in "sv", client in "cl".
NODE=$(command -v node); HERE=$(cd "$(dirname "$0")" && pwd)
ip netns del sv 2>/dev/null; ip netns del cl 2>/dev/null
ip netns add sv; ip netns add cl
ip link add name vsv type veth peer name vcl
ip link set vsv netns sv; ip link set vcl netns cl
ip -n sv addr add 10.1.0.2/24 dev vsv; ip -n sv link set vsv up; ip -n sv link set lo up
ip -n cl addr add 10.1.0.1/24 dev vcl; ip -n cl link set vcl up; ip -n cl link set lo up
ip netns exec sv $NODE $HERE/server.mjs & SRV=$!
sleep 1
for round in 1 2 3 4 5; do
for mode in single two; do
echo " [$mode connection(s)] round $round"
ip netns exec cl $NODE $HERE/client.mjs $mode ${DROP:-30}
ip netns exec cl nft delete table inet hol 2>/dev/null
done
done
kill $SRV; ip netns del sv; ip netns del cl
Node 20.19.2での5ラウンドの出力です(各セルは、そのストリームのデータイベントの間隔のうち最長のもので、単位はミリ秒です)。
| モード | ストリーム a(損失のある接続) | ストリーム b |
|---|---|---|
| 1本の接続を共有 | 315, 224, 274, 258, 260 | 316, 223, 269, 219, 225 |
| ストリームごとに接続 | 129, 132, 133, 126, 132 | 12, 13, 13, 12, 13 |
健全なストリームが12〜13msなのは、サーバーが10msおきに送るためです。接続が1本だと、100msの途切れが両方のストリームで219〜316msの沈黙になりました。接続を分けた場合は、損傷した接続のストリームだけが影響を受けました。TCPの中身は見ていないので、どの再送タイマーがどのラウンドで効いたのかは分からず、219〜316msのばらつきも説明できません。別の日に100msの条件を5ラウンド再実行すると、共有した接続の両ストリームは218〜225ms、接続を分けた場合は126〜139msと13〜15msでした。形は同じで、ばらつきは違いました。どの数字もTCPの性質として引用するつもりはありません。またこの実験はLinux専用で、ネットワークネームスペースと nft、そしてLinuxの再送の挙動に依存します。短い途切れ(DROP=30)では挙動が違い、1本の接続上の両ストリームが40〜52ms、つまり途切れと同程度の沈黙で、接続を分けた実行でも損傷したほうは同じ程度(40〜51ms)で、隣のストリームは12〜13msのままでした。つまりパケットを失った区間のコストは、その長さだけでは決まりません。共有の影響は、誰が一緒に待たされるかに表れます。
ここで、この停止中にリクエストの期限が来たとします。そのリクエストの RST_STREAM は何も解放しません。そのフレーム自体が、届いていない接続の上を通るからです。他のストリームも同じように静かです。期限切れは症状であって、必要なのは接続レベルの問いです。
接続に聞く: 自分の期限つきのPING
HTTP/2にはそのためのフレームがあります。PING は、「送信側から見た最小のラウンドトリップ時間の測定と、アイドル状態の接続がまだ機能しているかの判断」に使えます(RFC 9113 §6.7)。返信の期限はプロトコルが決めていません。自分で決めます。
// Is this HTTP/2 connection alive? PING frame + our own deadline. Then the path is black-holed.
import http2 from "node:http2";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function alive(session, ms) { // resolves true/false, never rejects
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), ms);
session.ping((err, rttMs) => { clearTimeout(timer); resolve(err ? false : rttMs); });
});
}
const session = http2.connect("http://10.1.0.2:9200");
session.on("error", () => {});
await new Promise((r) => session.once("connect", r));
const t0 = performance.now();
console.log("healthy path : alive() ->", await alive(session, 1000) !== false ? "true" : "false", `(PING round trip ${(await alive(session, 1000)).toFixed(2)} ms)`);
await run("nft", ["add", "table", "inet", "bh"]); await run("nft", ["add", "chain", "inet", "bh", "in", "{ type filter hook input priority 0; }"]);
await run("nft", ["add", "rule", "inet", "bh", "in", "ip", "saddr", "10.1.0.2", "drop"]);
const t1 = performance.now();
const ok = await alive(session, 1000);
console.log(`black-holed : alive() -> ${ok !== false} after ${(performance.now() - t1).toFixed(0)} ms (deadline 1000 ms)`);
session.destroy();
run2.sh の実行結果です(ネットワークの作り方は同じで、ストリーミングはなし。最初のPINGのあと、nft がサーバーからのパケットをすべて落とします)。
healthy path : alive() -> true (PING round trip 0.12 ms)
black-holed : alive() -> false after 1001 ms (deadline 1000 ms)
2行目はHTTP/2のことを何も示していません。プローブは自分のタイマーが切れたときに返ったのであって、それより早くではない、というだけです。黙ったままの接続は、自分が死んだとは教えてくれません。だから期限つきで聞く必要があります。どれだけ待つかは、検出の速さと、負荷や遅延の大きい経路での誤検知とのトレードオフで、環境で変わります。自分の環境でラウンドトリップを測って決めてください。
選択肢の比較
| 選択肢 | 得られるもの | 代償 |
|---|---|---|
| タイムアウトのたびに接続を閉じる | 実装が最も簡単 | 巻き添えの失敗(上で再現)。再接続のコスト。多数が同時にタイムアウトすると再接続の嵐 |
ストリームをキャンセル(HTTP/2の RST_STREAM)して接続は維持 | 遅いリクエストだけが失われる | サーバーが仕事を止める必要がある(後述)。停止がストリームより下なら効かない |
| ストリームをキャンセルし、無関係な複数が同時にタイムアウトしたらPING | 上に加えて、経路の死活を検出できる | 調整するタイマーが1つ増える |
| 1オリジンに複数接続 | パケット損失の影響が、その接続のストリームだけで済む | ハンドシェイクとメモリが増える。サーバー側の接続あたりの上限に当たる |
| HTTP/3(QUIC) | 1ストリームの損失が他を止めない(RFC 9000 §13) | ここでは未検証。使えるかはスタックとネットワーク次第 |
タイムアウトまわりの失敗
| 見えること | よくある原因 | 対策 |
|---|---|---|
| 遅いエンドポイントが1つあるだけで、無関係な失敗がまとまって出る。接続数が跳ね上がる | タイムアウトのたびにセッション全体を閉じている(上で再現しました) | リクエストだけキャンセルする。接続を閉じるのは、プローブ失敗、GOAWAY、プロトコルエラーのときだけにする |
| エラーなしで、別のリクエストのデータが返る | 返信を位置で対応づけている(上で再現しました) | idを足す。未知のidの返信は捨てる |
| 負荷をかけるとメモリがじわじわ増える。誰も待っていないPromiseが遅い返信で解決される | タイムアウト時に保留表を掃除していない(上のidの実行では表は0で終わりました) | 一部のリクエストをわざとタイムアウトさせる負荷試験で確かめる |
| クライアントが諦めたのに、CPUやDBの負荷が高いまま | キャンセル後もサーバーが仕事を続けている。RST_STREAM はストリームが終わったことを知らせるだけで、ハンドラーが自分で見る必要がある(h2_timeout.mjs のサーバーは、応答する前に stream.destroyed を見ています)。大規模では測っていません | 実際に重い処理をするハンドラーすべてで、キャンセルを確認する |
| 遅いサーバーをさらに遅くする再接続ループ | 複数のタイムアウトを、接続が死んだ証拠とみなしている | まず PING を送る |
| すでに遅いサーバーへの負荷が増える | 劣化した経路への即時の再試行(ここでは測っていません。よくある決まりで、最初の2行と一緒に出やすいので挙げました) | ジッター付きのバックオフと、再試行回数の上限 |
| 普段は何も起きない | HTTPクライアントが、中断時にストリームをキャンセルしてくれるという思い込み。特定のライブラリやブラウザは確認していません | 頼る前に、サーバーのログかパケットキャプチャで確かめる |
再現する
h2_timeout.mjsを保存し、node h2_timeout.mjsを実行します。成功なら、close-sessionの行にFAILEDが2つ、close-streamの行にokが2つ、どちらもTCP connections openedが1です。npm i ws@8のあとmux_lab.mjsを保存して実行します。成功なら、positionalにasked for D, got "A"、by idにTIMEOUT B Cとgot "D"が出ます。- Linuxでroot、
ip、nftが使えるなら、server.mjs、client.mjs、pingtest.mjs、run.sh、run2.shを1つのディレクトリに置きます。停止の表はsudo DROP=100 bash run.sh、PINGの実験はsudo bash run2.shです(rootのPATHにnftがなければ/usr/sbinを足します)。スクリプトは名前空間svとclを作って消します。 DROPを100から30や300に変え、見る前に沈黙の長さを予想してください。mux_lab.mjsのAが期限内に終わるように変え、2つのクライアントで3つの返信が揃うことを確かめてください。
この結果が届く範囲
確認できたこと: 1台のマシン(Linux 6.12、Node 20.19.2)で、上の出力を、表示されたとおりに確認しました。パケット損失の実験は、2種類の長さ(100msと30ms)でモードごとに5ラウンド、さらに後日、100msの条件を5ラウンド再実行しました。PINGの実験は1回実行し、1回再現しました。引用したRFC 9113とRFC 9000の節は、書く際にrfc-editor.orgで読みました。
確認できていないこと: 実ネットワークやミドルボックス、TLS、HTTP/3、ブラウザやHTTPクライアントライブラリの中断時の挙動、219〜316msのばらつきの正確な原因、キャンセル後に実負荷がかかったときのサーバーの挙動。期限の値(500ms、1000ms)は実験用の選択で、推奨値ではありません。nft をブロッキング呼び出しにした初期の試行は、イベントループを止めるため誤った数字を出しました。ここのスクリプトは非同期版です。
期限はリクエストのもの
期限はリクエストのもので、リクエストにはidがあります。タイムアウトしたものだけをキャンセルします。接続の生死は別に、PING と自分で決めた期限で確かめ、答えなかったときだけ閉じます。