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?

Bun 1.4.1 の AsyncLocalStorage、メモリだけ 25 倍漏れていた

0
Posted at

TL;DR

  • Bun 1.4.1 には AsyncLocalStorage の回帰があり、store.exit() やネストした als.run() の内側で作ったタイマー・Promise が 外側の store を掴んだまま になります。
  • 8MB の store を 40 個作る同一コードで実測したところ、RSS の純増は 1.4.1 が 329MB、1.4.2 が 13MB でした。約 25 倍の差です。
  • やっかいなのは getStore() の戻り値が 1.4.1 でも正しいことです。exit() の中では undefined、ネストの内側では内側の store が返ります。振る舞いのテストは全部通ります。落ちるのはメモリだけです。
  • 同じ 1.4.2 で worker_threads の 'online' イベント順序も直っています。1.4.0 / 1.4.1 は 'message' が先に届き、1.4.2 で Node.js と同じ 'online' → 'message' に戻りました。
  • 1.4.1 を踏んでいる場合、アプリのコードを疑う前に bun upgrade を先に試す価値があります。

はじめに

Bun でリクエストコンテキストを持ち回している開発者、とくに AsyncLocalStorage をロガーやトレース ID の受け渡しに使っている方が対象読者です。

AsyncLocalStorage は「非同期処理をまたいで値を運ぶ」ための仕組みなので、値が正しく取れているかは誰でもテストします。では、値は正しく取れているのに 参照だけが残り続けている 状態は、どうやって気づけばよいのでしょうか。Bun 1.4.1 で起きていたのは、まさにその種類のバグでした。

Bun 1.4.2 のリリースノートには「store.exit() や入れ子の store.run() の内側で作られたタイマー・immediate・pending な Promise が、その寿命のあいだ外側の store の値を保持していた」「getStore() は正しい値を返していたが、メモリが不必要に消費されていた」と書かれています1。この記述が実測でどう現れるのかを確かめました。

検証環境

項目 値
OS Ubuntu 24.04.4 LTS
カーネル Linux 6.18.44-fc-v24 x86_64
CPU / メモリ 4 コア / 16GB
比較対象 Bun 1.4.0(2026-08-20)/ 1.4.1(2026-09-04)/ 1.4.2(2026-09-05)2
参照実装 Node.js v22.22.2

Bun はバージョンごとに BUN_INSTALL を分けて入れました。

for V in 1.4.0 1.4.1 1.4.2; do
  BUN_INSTALL="/tmp/bun-$V" bash -c 'curl -fsSL https://bun.sh/install | bash -s "bun-v'$V'"'
done

何が漏れているのか

AsyncLocalStorage の store は、als.run(store, fn) の fn から派生した非同期処理が生きているあいだ保持されます。これは仕様どおりの挙動です。逆に als.exit(fn) はコンテキストから明示的に抜けるための API なので、その内側で作った非同期処理は外側の store を保持しないはずです。

1.4.1 で壊れていたのは、この「保持しないはず」の側でした。

実測 1: パターン別に RSS の純増を測る

4 つのパターンで、8MB の store を 40 個作り、それぞれ 60 秒のタイマーを登録した状態の RSS 純増を測りました。

  • plain: als.run() の中でタイマーを作る(store を保持するのが正しい。対照群)
  • exit: als.run() の中で als.exit() し、その中でタイマーを作る
  • nested: als.run() の中でさらに als.run() し、内側でタイマーを作る
  • promise: als.exit() の中で pending な Promise を作る
// c2-patterns.js(抜粋)
const { AsyncLocalStorage } = require("async_hooks");
const als = new AsyncLocalStorage();
const BIG = 8 * 1024 * 1024;
const timers = [];
const base = Math.round(process.memoryUsage().rss / 1024 / 1024);

for (let i = 0; i < 40; i++) {
  const store = { payload: Buffer.alloc(BIG, 1) };
  if (MODE === "plain") {
    als.run(store, () => { timers.push(setTimeout(() => {}, 60000)); });
  } else if (MODE === "exit") {
    als.run(store, () => { als.exit(() => { timers.push(setTimeout(() => {}, 60000)); }); });
  } else if (MODE === "nested") {
    als.run(store, () => { als.run({ small: i }, () => { timers.push(setTimeout(() => {}, 60000)); }); });
  } else if (MODE === "promise") {
    als.run(store, () => { als.exit(() => { timers.push(new Promise(r => setTimeout(r, 60000))); }); });
  }
}

setTimeout(() => {
  const rss = Math.round(process.memoryUsage().rss / 1024 / 1024);
  console.log(`mode=${MODE} base=${base}MB rss=${rss}MB delta=${rss - base}MB`);
  process.exit(0);
}, 800);

結果(delta = RSS の純増、単位 MB):

パターン Bun 1.4.1 Bun 1.4.2 Node.js v22
plain(対照群・保持が正しい) 326 325 321
exit 329 13 33
nested 329 13 40
promise 329 13 32

対照群の plain は 3 者ともほぼ同じ 320MB 台です。8MB × 40 = 320MB がそのまま保持されており、これは仕様どおりです。この列が揃っていることで、測定方法そのものは妥当だと言えます。

問題は残り 3 パターンです。1.4.1 だけが plain と同じ 329MB を抱えています。つまり exit() してもネストしても、外側の store がまったく解放されていません。1.4.2 と Node.js は 13MB / 32〜40MB に収まっており、8MB の store 40 個ぶんは解放されています。

なお 1.4.2 の 13MB が Node.js の 32〜40MB より小さいのは、ランタイムのベースライン RSS の差(測定開始時点で Bun 18MB / Node 42MB)と GC の挙動差によるもので、リークの有無とは別の話です。ここで比べるべきは「plain との差」であって、絶対値の大小ではありません。

実測 2: 漏れ方は store の数に比例する

exit パターンで store の数を変えて測り直しました。

store の数 Bun 1.4.1 Bun 1.4.2
10 84MB 5MB
20 165MB 7MB
40 330MB 13MB

1.4.1 は 84 → 165 → 330 と、store の数にきれいに比例して増えています。8MB × N がそのまま積み上がる形です。1.4.2 は 5 → 7 → 13 と、タイマーオブジェクトぶんしか増えません。

比例するということは、リクエストごとに store を作る Web アプリでは、負荷に比例して漏れる ということです。ローカルの短時間テストでは 84MB 程度に見えても、本番の秒間数十リクエストではそのままメモリ使用量の傾きになります。

実測 3: getStore() は 1.4.1 でも正しい

ここが本記事でいちばん伝えたい点です。値の取得だけを見ても、このバグは見つかりません。

// d-getstore.js
const { AsyncLocalStorage } = require("async_hooks");
const als = new AsyncLocalStorage();

als.run({ id: "outer" }, () => {
  als.exit(() => {
    setTimeout(() => {
      console.log("inside-exit-timer getStore=" + JSON.stringify(als.getStore()));
    }, 10);
  });
  als.run({ id: "inner" }, () => {
    setTimeout(() => {
      console.log("nested-timer getStore=" + JSON.stringify(als.getStore()));
    }, 20);
  });
});

実行結果は Bun 1.4.0 / 1.4.1 / 1.4.2 / Node.js v22 の 4 者すべてで同一 でした。

inside-exit-timer getStore=undefined
nested-timer getStore={"id":"inner"}

exit() の中では undefined、ネストの内側では内側の store。どちらも期待どおりです。1.4.1 でもコンテキストの伝播ロジックは壊れていません。壊れていたのは「もう使わない参照を切る」部分だけでした。

言い換えると、AsyncLocalStorage に対して書くであろうユニットテスト(「トレース ID が正しく引き継がれるか」「exit() すると値が消えるか」)は、1.4.1 でも全部グリーンになります。CI は何も教えてくれません。

実測 4: worker_threads の 'online' 順序も 1.4.2 で直っている

同じ 1.4.2 では worker_threads の修正も入っています。Worker が 'online' を最初に発火するよう順序が是正され、これによって @discordjs/ws のハングが解消したとされています1。

// b-worker.js
const { Worker, isMainThread, parentPort } = require("worker_threads");
if (!isMainThread) {
  parentPort.postMessage("hello-from-worker");
} else {
  const order = [];
  const w = new Worker(__filename);
  w.on("online", () => order.push("online"));
  w.on("message", () => { order.push("message"); w.terminate(); });
  w.on("exit", () => { console.log("ORDER=" + order.join(",")); });
}
ランタイム 出力
Bun 1.4.0 ORDER=message
Bun 1.4.1 ORDER=message
Bun 1.4.2 ORDER=online,message
Node.js v22 ORDER=online,message

1.4.0 / 1.4.1 では 'message' が先に届き、その時点ではまだ 'online' が発火していません。ワーカーを terminate した後に届いても、'message' を起点に処理を進める実装にとっては手遅れです。

ただし、'online' を単独で待つだけなら 1.4.1 でも実害は出ませんでした。events.once(w, "online") で待つ形にすると、1.4.0 が 19ms、1.4.1 が 14ms、1.4.2 が 2ms、Node.js が 67ms で、どれも正常に解決します。壊れるのは「'online' を待ってから 'message' を待つ」ような 順序に依存した実装 だけです。

再現できなかったもの

1.4.2 のリリースノートには、bun build が同じブロック内の let バインディングと衝突する形で入れ子の var をリネームしてしまい、SyntaxError: Cannot declare a var variable that shadows a let/const/class variable を出す回帰も挙げられています1。Elysia アプリで踏まれたものです。

let と var と catch バインディングを同じ関数に詰め込んだ最小コードを bun build --minify に通してみましたが、1.4.0 / 1.4.1 / 1.4.2 のいずれでも同じ結果になり、再現できませんでした。リネームの衝突はスコープの形と識別子の割り当て順に依存するため、実アプリのバンドル規模でないと踏めない類のものだと考えています。ここは「筆者の最小ケースでは出なかった」という事実だけを記しておきます。

著者視点の発見ポイント

筆者がこの検証で最も気になったのは、このバグが「監視で気づく」側にしか現れない ことです。

AsyncLocalStorage は本来「値が正しく運ばれるか」だけを気にする API です。そこにテストを書き、CI を回し、ステージングで動作確認をする。そのすべてを 1.4.1 は通過します。異常が出るのは本番のメモリグラフだけで、しかもその形は「じわじわ右肩上がり」という、原因の特定が最も面倒な形になります。

さらに厄介なのが、plain パターンとの見分けがつかないことです。実測 1 で示したとおり、1.4.1 の exit は plain と同じ 329MB を示します。メモリダンプを取っても「store が保持されている」という事実しか見えず、それが仕様どおりの保持なのか回帰による保持なのかは、exit() を呼んでいるはずのコードパスを読み直すまで区別できません。実際、筆者も最初は測定コードのほうを疑いました。対照群として plain を並べて初めて「exit が plain と一致してしまっている」という異常が見えた形です。

対照群を置かない実験は、こういうときに何も教えてくれません。

実務でどうするか

  1. バージョンを確認する。bun --version が 1.4.1 なら、まず bun upgrade を試します。1.4.0 は AsyncLocalStorage については影響を受けていません(実測で 30MB)。影響範囲は 1.4.1 の 1 バージョンに限られます。
  2. plain を対照群にして測る。自分のアプリで疑わしいときは、als.exit() を使っているパスと使っていないパスで RSS の傾きを比べます。両者が一致したら、それは exit() が効いていないサインです。
  3. worker_threads を使っているなら順序を確認する。'online' と 'message' の両方を待つ実装は、1.4.1 以前で順序が入れ替わります。events.once() で単独に待つ形に変えると影響を受けません。
  4. メモリ回帰は振る舞いテストで検知できない前提を持つ。今回のように getStore() が正しい値を返し続けるケースでは、RSS を計測する軽いスモークテストを CI に 1 本足すほうが早く気づけます。

Bun 1.4.1 は 2026-09-04、1.4.2 は翌日の 2026-09-05 のリリースです2。踏んでいた期間は 1 日と短いものの、その 1 日にデプロイを重ねていた環境では、原因がアプリ側にあると誤診しやすいバグでした。ランタイムのパッチバージョンを疑うという選択肢は、思っているより早い段階で出してよいのだと思います。

関連記事

  1. Bun 公式ブログ「Bun v1.4.2」 https://bun.com/blog/bun-v1.4.2 ↩ ↩2 ↩3

  2. GitHub Releases(oven-sh/bun)v1.4.0 = 2026-08-20 / v1.4.1 = 2026-09-04 / v1.4.2 = 2026-09-05 https://github.com/oven-sh/bun/releases ↩ ↩2

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?