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 と一致してしまっている」という異常が見えた形です。
対照群を置かない実験は、こういうときに何も教えてくれません。
実務でどうするか
-
バージョンを確認する。
bun --versionが1.4.1なら、まずbun upgradeを試します。1.4.0 はAsyncLocalStorageについては影響を受けていません(実測で 30MB)。影響範囲は 1.4.1 の 1 バージョンに限られます。 -
plainを対照群にして測る。自分のアプリで疑わしいときは、als.exit()を使っているパスと使っていないパスで RSS の傾きを比べます。両者が一致したら、それはexit()が効いていないサインです。 -
worker_threadsを使っているなら順序を確認する。'online'と'message'の両方を待つ実装は、1.4.1 以前で順序が入れ替わります。events.once()で単独に待つ形に変えると影響を受けません。 -
メモリ回帰は振る舞いテストで検知できない前提を持つ。今回のように
getStore()が正しい値を返し続けるケースでは、RSS を計測する軽いスモークテストを CI に 1 本足すほうが早く気づけます。
Bun 1.4.1 は 2026-09-04、1.4.2 は翌日の 2026-09-05 のリリースです2。踏んでいた期間は 1 日と短いものの、その 1 日にデプロイを重ねていた環境では、原因がアプリ側にあると誤診しやすいバグでした。ランタイムのパッチバージョンを疑うという選択肢は、思っているより早い段階で出してよいのだと思います。
関連記事
- 17.5kスターのomp、bun installはBun 1.3.11でも素通りした
- Rust製MCPサーバーをcargoに繋いだら、ツール定義だけで66KB消えた
- Go 1.27のgoroutine leak profile、Countは0でも5件検出した
-
Bun 公式ブログ「Bun v1.4.2」 https://bun.com/blog/bun-v1.4.2 ↩ ↩2 ↩3
-
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