Skip to content

fix: 描画ごとに永久保持されるメモリを止めてヒープ枯渇(OOM)を防ぐ - #92

Merged
takecchi merged 2 commits into
mainfrom
codiva/codiva-7
Aug 5, 2026
Merged

fix: 描画ごとに永久保持されるメモリを止めてヒープ枯渇(OOM)を防ぐ#92
takecchi merged 2 commits into
mainfrom
codiva/codiva-7

Conversation

@takecchi

@takecchi takecchi commented Aug 5, 2026

Copy link
Copy Markdown
Owner

何をしたか

~/.codiva/logs/ に残っていた Node の診断レポート 3 件を調査したところ、0.3.8 でもヒープ枯渇(OOM)で落ちていたことが分かったので、その原因を潰しました。Phase 23(#87)で直したものとは別のバグです。

原因

診断レポートの読み方から始めています。

14:34 23:45 23:50
cwd codiva glucose-flight-frontend codiva
event Allocation failed - JavaScript heap out of memory 同じ 同じ
old_space 4.23GB(使用率ほぼ 100%) 4.2GB 4.23GB
large_object_space 55MB 同程度 同程度
消費 CPU 時間 1628 秒 1987 秒 1571 秒

old_space が生存データで埋まる一方 large_object_space は 55MB だけ = 小さいオブジェクトの保持漏れ(Phase 23 の Ineffective mark-compacts = 確保レート過多とは別物)。ヒープスナップショットを取ると上位が PerformanceMeasure × 60,003(= 20,000 描画 × 3)と Components ⚛ / Changed Props / Scheduler ⚛ で、正体は React 19.2 の Performance Tracks でした。

  • react-reconciler は dev ビルドのモジュール評価時supportsUserTimingconsole.timeStamp && performance.measure。Node には両方ある)を確定し、以後レンダーごとに performance.measure() を 3 本積む。
  • Node の user timing は呼んだ側が捨てるまで保持され続ける(ブラウザの devtools が消費する前提の API)。
  • bin から node で直に起動され NODE_ENV は未設定 = 利用者は必ず dev ビルドだった。

描画内容に一切依存しないので、ログの上限では止まりませんでした。なお同じ結論が上流に既報でした(ink#869 / facebook/react#35761。どちらもクローズ済み・対策も同じ)。

実測

--expose-gc + 強制 GC 後の heapUsed 差分。空 Box を 8,000 回再描画(= 描画内容ゼロ)。

条件 永久保持 perf エントリ 所要
dev ビルド(従来) 2,230 B/フレーム 24,003 件 414ms
NODE_ENV=production 117 B/フレーム 0 件 166ms
dev + 定期 clearMeasures() 174 B/フレーム 1 件 406ms

描画は約 10/秒(ストア購読の ~100ms スロットル)⇒ 約 86MB/時。既定のヒープ上限 ~4GB に半日〜1 日で到達します。production ビルドは描画自体も 2.5 倍速い(報告された 26〜33 分の CPU 時間の相当部分がこれ)。

変更点

  1. src/index.tsx を起動シムにする(本体は src/main.tsx へ改名)
    process.env.NODE_ENV ??= 'production'await import('./main') より前に置きます。ESM の static import は巻き上げられて本文より先に評価されるため、tsupbanner では間に合いません。シバンに env -S NODE_ENV=production を書く手も、mise 経由の起動が node <path> 直叩きでシバンを通らないため当てになりません(実際のクラッシュレポートの commandLine がそれ)。tsupsplitting: true が必須で、distindex.js(113B のシム)+ main-<hash>.js(本体)の 2 ファイルになります。bin が指すパスと packageRootFrom の前提(package.json の 1 つ下)は変わりません。
    → 番人として tests/entry-shim.test.ts がシムに static import が無いこと・代入が動的 import より前にあることを固定します(1 本足すだけで見た目に気づけないまま壊れるため)。
  2. bootstrap/perf-timeline.ts(保険): 30 秒ごとに performance.clearMeasures() / clearMarks()NODE_ENV=development で起動したときや、将来 React / Node が別の形で user timing を積み始めたときに効きます。タイマーは unref、失敗は握り潰し。
  3. ストリーミングプレビューを表示幅で切る: streamTail(text, width) + 純粋な clipToWidth(グラフェム単位・早期打ち切り・ANSI を含む行は切らない)。Ink 7.1.1 の measure-text.js / wrap-text.jsキー = テキスト全文の上限なしキャッシュで、4,000 文字の <Text> 1 描画で約 17.8KB が永久に残ります。wrap="truncate-end" は描画時に切るだけなのでキーは切る前の文字列 = 効きません。見た目は不変で、行が幅を超えたあとは文字列が変化しなくなるためキャッシュに当たるようになります(4,000 文字の最悪ケースで 6,786 → 3,129 B/フレーム)。渡す幅はログ行の折返しと同じ logWidth を共有します。
  4. startPrPollingvoid manager.refreshPrs().catch()(規約違反の修正)。20 秒ごとの reject が unhandled rejection = プロセス死になり、死因が OOM と見分けづらいため、OOM 調査中は特に紛らわしい箇所でした。

テスト計画

  • npm run lint / npm run typecheck / npm test2175 件パス、カバレッジ statements 95.7% / branches 90.7%)/ npm run build
  • node dist/index.js --reset-terminalnpx tsx src/index.tsx --reset-terminal の両経路が起動する(シムを通ることの確認)
  • ビルド出力の dist/index.js に static import が無いことを目視 + テストで固定
  • 実機での体感確認はお願いします(TTY と認証が必要なため非対話セッションでは実行できません)。長時間動かしたときに RSS が増え続けないか、~/.codiva/logs/ に新しい report.*.json が出ないかを見ていただけると確実です

残した課題

  • Ink のキャッシュ自体に上限が無いことは上流の修正が必要なので issue を出しました → ink#986(未報告のバグでした。再現コードと実測付き)。新しく現れたログ行 1 本ごとに約 1.7KB が永久に残ります。codiva 側で上限を付けるには noExternal: ['ink'] でバンドルして LRU 化する必要があり(signal-exit の CJS require シム + チャンク分割 + react-devtools-core の external が付き、dist は 322KB → 1.8MB。実験済み)、「ビルド構成は変えない」方針との兼ね合いで見送っています。
  • 調査の副産物として見つかった本件と独立な別バグdocs/TASKS.md に次 Phase の候補として残しました: SessionStore.set による削除済みセッションの復活 / canUseTool の pending が 1 スロットで並行要求を取りこぼす(サブプロセスが永久待機になる)/ AsyncQueue の待機 resolver が死んだイテレータへ配送する / discard・merge が SessionHandle を解放しない。いずれもメモリの主因ではありませんが実害があります。

0.3.8 でも `Allocation failed - JavaScript heap out of memory` で 3 回落ちていた
(`~/.codiva/logs/report.*.json`)。Phase 23 のログ上限とは別原因で、`old_space` 4.2GB が
生存データで埋まる一方 `large_object_space` は 55MB だけ = 小さいオブジェクトの保持漏れ。

ヒープスナップショットの上位が `PerformanceMeasure` × 60,003(= 20,000 描画 × 3)と
`Components ⚛` / `Changed Props` / `Scheduler ⚛` で、正体は React 19.2 の Performance Tracks。
`react-reconciler` の dev ビルドがモジュール評価時に `supportsUserTiming`
(`console.timeStamp` && `performance.measure`。Node には両方ある)を確定し、以後レンダーごとに
`performance.measure()` を 3 本積む。Node の user timing は自動で捨てられないため、
描画内容に関係なく約 2,230 B/フレーム(描画は約 10/秒 ⇒ 約 86MB/時)で増え続けていた。
`bin` から `node` で直に起動され `NODE_ENV` が未設定なので、利用者は必ず dev ビルドだった。

- `src/index.tsx` を起動シムにする(本体は `src/main.tsx`)。`NODE_ENV ??= 'production'` を
  `await import('./main')` より前に置く。ESM の static import は巻き上げられるため banner では
  間に合わず、シバンの `env -S` も mise 経由の `node <path>` 起動では通らない。
  `tsup` は `splitting: true` が必須
- `bootstrap/perf-timeline.ts`: 30 秒ごとに user timing を掃除(dev ビルドで起動したとき用の保険)
- ストリーミングプレビューを表示幅で切る(`streamTail(text, width)` + `clipToWidth`)。
  Ink 7.1.1 の `measure-text` / `wrap-text` は上限なしキャッシュ(キー = テキスト全文)で、
  4,000 文字の `<Text>` 1 描画で約 17.8KB が永久に残る。`wrap="truncate-end"` は描画時に
  切るだけなのでキーは切る前の文字列 = 効かない
- `startPrPolling` の `void manager.refreshPrs()` に `.catch()`(規約違反。20 秒ごとの reject が
  プロセス死になり、死因が OOM と見分けづらい)

実測(`--expose-gc` + 強制 GC 後の heapUsed 差分。空 Box を 8,000 回再描画):
dev 2,230 B/フレーム・414ms → production 117 B/フレーム・166ms(2.5 倍速)。

番人として `tests/entry-shim.test.ts` がシムに static import が無いことを固定する。
@takecchi
takecchi merged commit f6fe0b9 into main Aug 5, 2026
1 check passed
@takecchi
takecchi deleted the codiva/codiva-7 branch August 5, 2026 17:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant