ブラウザ自動化ツール(Puppeteer、Playwright、Chrome 拡張機能、Claude in Chrome など)を用いて、Web アプリケーション上で長時間のシミュレーションや連続した検証試行を実行していると、およそ 45秒(45,000ms)前後で突然コマンドが異常終了し、呼び出し元への戻り値の返却が途絶する壁に突き当たることがあります。

Chrome DevTools Protocol(CDP)の Runtime.evaluate タイムアウトとは、ブラウザのデバッグ接続経由で実行された JavaScript の評価が所定の時間制限(ツール既定で 45 秒など)を超過した際にクライアント側で通信が切断される現象であり、非同期スクリプトであっても全体の実行時間が単一コマンドの待機上限を超えることによって発生します。

このタイムアウトに対して「タイムアウト設定を延長する」「処理の合間に待機(sleep)を挟む」といった対症療法を試みても、一度切断された evaluate のインメモリ戻り値(途中の推移配列など)は回収できません。どれほど画面が最後まで進んでいても、長時間の検証ループはそこで止まってしまいます。

この問題に対する確実な解決策は、同一の Rust/Wasm ソースから wasm-bindgen --target nodejs を用いて Node.js 実行環境へ計算責務を物理的に分離し、推移表の全ステップをヘッドレスに完走させたうえで、最終状態(終端値)をブラウザ画面の描画値と突き合わせることです。ブラウザは「画面の描画と最終確認」、重い計算は「Node.js 上の Wasm」と責務を物理的に分離し、終端値が画面と一致するかを照合する方がはるかに堅牢です。

本記事では、自作の Web 経営シミュレーションゲーム(以下、プロジェクトA)における実務検証ログに基づき、CDP タイムアウトの発生メカニズムと、Wasm のマルチターゲット出力を活用した検証アーキテクチャを解説します。

検証環境と調査スコープ

本記事で報告する検証は、以下の環境およびスコープで実施しました。

項目設定・バージョン
OSmacOS(Apple Silicon)
実行環境Node.js, Chromium(Google Chrome)
自動化ツールChrome 拡張(Claude in Chrome、javascript_tool / action: javascript_exec)
ビルドツールRust, wasm-bindgen(CLI)
デバッグプロトコルChrome DevTools Protocol(CDP)Runtime.evaluate
計測実施時期2026年10月

※本検証で確認した挙動は特定のパッチバージョンに依存せず、CDP の Runtime.evaluate 仕様および wasm-bindgen の --target nodejs 標準機能に基づきます。

本検証のスコープは以下の4点です。

  1. ブラウザ内での長時間シミュレーション実行時におけるタイムアウト挙動の観察
  2. CDP のプロトコル仕様と Chromium のマルチプロセスアーキテクチャの照合
  3. wasm-bindgen --target nodejs によるヘッドレス直接実行と推移表の取得
  4. ブラウザ画面テキストと Node.js 実行結果の終端突き合わせ

CDP のタイムアウト制約とレンダラ無応答のメカニズム

なぜブラウザ内で重い処理を回すと 45 秒前後で通信が切れてしまうのか、その内部仕様とアーキテクチャ的要因を整理します。

Runtime.evaluate のプロトコル仕様

Chrome DevTools Protocol(CDP)における Runtime.evaluate は、指定したコンテキスト内で JavaScript 式を評価するための基本コマンドです1。

このコマンドは expression、awaitPromise、returnByValue、timeout などのパラメータを持ち、デバッガクライアントがブラウザ内の状態を取得・操作する際の標準的なトランスポートとなっています。Chrome 拡張機能から CDP を利用する場合は、chrome.debugger API の sendCommand を通じてブラウザプロセスへコマンドが送信されます2。

Chromium のマルチプロセスアーキテクチャと IPC

Chromium は、全体の UI や拡張機能セッションを管理する Browser Process と、各タブの Web コンテンツを描画・実行する Renderer Process が物理的に分離されたマルチプロセスモデルを採用しています3。

デバッグコマンドは Browser Process からプロセス間通信(IPC: Mojo)を経由して該当タブの Renderer Process へ配送されます。Renderer Process 内部では Blink レンダリングエンジンが動作しており、JavaScript の実行はレンダラのメインスレッド上でタスクとしてスケジューリングされます4。

クライアント側タイムアウトと「副作用の非同期完走」の不可逆性

長時間にわたるシミュレーション処理では、UI の更新を伴いながら数十期〜数百期に及ぶループ処理を実行します。各ステップの合間に await sleep(...) を挟んでメインスレッドへの配慮を行っていたとしても、処理全体を単一の Runtime.evaluate コールの中で非同期待機させている場合、全体の所要時間は容易に数十秒に達します。

クライアント側の自動化ツール(Chrome 拡張など)は、単一の evaluate 呼び出しに対して所定の時間上限(本検証環境では 45,000ms = 45秒)を超えて応答がない場合、通信タイムアウト例外を発行して Promise 待機を強制的に打ち切ります。この際、エラーメッセージには定型文として The renderer may be frozen or unresponsive が付与されます。

しかし、ここで極めて重要なのは 「クライアント側で通信がタイムアウトしても、レンダラプロセス側で実行中のスクリプト自体がキャンセルされるわけではない」 という点です。ブラウザ側ではバックグラウンドでスクリプトが走り続け、計算自体は最後まで完走します(副作用の非同期完走)5。

問題の本質は、切断された evaluate 呼び出しの Promise はクライアント側ですでに破棄されているため、計算が完了した後にスクリプトが末尾で返そうとしていたインメモリの配列データ(推移ログなど)は二度と受け取れない という不可逆性にあります。

ブラウザ内実行でのタイムアウトと戻り値欠落の観測

プロジェクトAの開発サーバー上で、実際に 50 年分(1,250期)のシミュレーションループを Chrome 拡張から実行した際の挙動を観察しました6。

ブラウザ内シミュレーション評価スクリプト

ブラウザの画面上で各期のターン進行(window.advance)、施策変更、建設操作を行い、5年ごとに画面数値をスナップショットして配列 log に蓄積し、最後に log.join('\n') を返却するスクリプトを実行しました。

引用元: プロジェクトA(非公開)のシミュレーション評価スクリプト(検証作業ログより抜粋)

const sleep=ms=>new Promise(r=>setTimeout(r,ms));
const sels=document.querySelectorAll('select'); const inputs=document.querySelectorAll('input');
const B=t=>[...document.querySelectorAll('button')].find(b=>b.textContent.startsWith(t));
const set=(el,v)=>{el.value=v; el.dispatchEvent(new Event(el.tagName==='SELECT'?'change':'input',{bubbles:true}));};
const L=()=>document.querySelector('ul').innerText.split('\n'); const num=s=>+s.replace(/,/g,'');
const gold=()=>num(L()[3].match(/国庫 ([\d,]+)/)[1]); const free=()=>num(L()[2].match(/空き ([\d,]+)/)[1]);
const tax=async(m)=>{for (const k of ['agriculture','inn','tavern','smithy']){ set(sels[1],k); set(inputs[1],m[k]); B('税率を変える').click(); await sleep(15);} };
const build=async(f)=>{ set(sels[0],f); set(inputs[0],1); B('建設').click(); await sleep(15); };
const log=[]; const snap=y=>{const l=L(); log.push(`${y}年: ${l[0].replace('人口 ','')} | 収容${l[1].match(/収容人口 ([\d,]+)/)[1]} | 許容${l[2].match(/許容量 ([\d,]+)/)[1]} | ${l[3]}`);};
snap(0);
for (let y=1;y<=50;y++){
  if (y===1) await tax({agriculture:50,inn:75,tavern:100,smithy:50});
  if (y===11) await tax({agriculture:0,inn:0,tavern:0,smithy:0});
  if (y===26) await tax({agriculture:50,inn:75,tavern:100,smithy:50});
  for (let i=0;i<40;i++){ if (gold()<8000) break; if (free()<6){ B('塀を広げる').click(); await sleep(15); continue; }
    for (const f of ['housing','water','agriculture','sanitation']) await build(f);
    if (y>=26) { await build('tavern'); await build('inn'); } }
  window.advance(25); await sleep(40);
  if (y%5===0) snap(y);
}
window.scrollTo(0,0); log.join('\n')

実行開始から 45 秒後、以下のエラーが出力されて処理が途絶しました。

Failed to execute JavaScript: CDP sendCommand "Runtime.evaluate" timed out after 45000ms on tab 686381119. The renderer may be frozen or unresponsive.

このスクリプトでは、各期の進行と DOM 操作の合間に await sleep(15) や await sleep(40) を挟んでいましたが、50 年分の累積実行時間が 45 秒の上限を超過したため、戻り値の文字列化(log.join('\n'))の直前で通信が打ち切られました。

待機後の状態確認とデータの消失

エラーメッセージには「レンダラが無応答(unresponsive)になっている可能性がある」と示されていたため、クラッシュしたのかどうかを確認すべく、直後に 10 秒間の待機(wait 10)を行いました。

待機後、タブのタイトル h1 を取得すると正常に応答し、さらに画面のテキスト(document.querySelector('ul').innerText)を取得したところ、以下の内容が返ってきました。

人口 1,300: 男児 163 / 女児 163 / 成人男 349 / 成人女 332 / 老人男 56 / 老人女 49 / 冒険者 188
収容人口 6,470(住居 6,470 / 水 6,470 / 食料 6,470 / 衛生 6,470)
街の許容量 3230、規模の使用量 3226(空き 4)、工事中 0 件
国庫 146,473
ダンジョン資産 999,999,850,137,136
天体: 季節 第 1 節気 / 速い月 10% / 遅い月 23%
施設の規模: 住居 647 水 647 下水 647 農地 647 宿屋 309 酒場 309 鍛冶屋 20 
税率(‰): 農業 50 宿屋 75 酒場 100 鍛冶屋 50 

画面上には 50年目の最終数値が表示されており、シミュレーション処理自体はバックグラウンドで最後まで完走していたことが実証されました。

しかし、画面上に表示されているのは「50年目という終端のスナップショット」のみです。スクリプト内のインメモリ配列 log に記録されていた 5 年ごとの推移データは、通信切断によって完全に失われており、後から回収する手段は存在しませんでした。

実行環境の分離(ブラウザ内 vs Node.js ヘッドレス)の比較

タイムアウトへの対策として、自動化ツールのタイムアウト設定値を延長するというアプローチが真っ先に検討されがちです。たとえば Puppeteer には protocolTimeout(デフォルト180秒)を引き上げる設定が存在し7、タイムアウト時の回避策として言及されることがあります8。

しかし、Playwright の公式ドキュメントでも言及されているように、テストの不安定さ(flaky)への対処として低レベルのタイムアウトを安易に引き上げる運用は推奨されません9。長時間の同期的あるいは擬似同期的な処理をブラウザのメインスレッド上で回し続けることは、メモリ消費の肥大化やレンダラプロセスのクラッシュリスクを伴い、本質的な安定性をもたらさないためです。

ブラウザの CDP 経由で重い計算を実行するアプローチと、同一ロジックを Node.js に切り出してヘッドレスに実行するアプローチには、実行モデルと安定性の面で明確なトレードオフが存在します。

比較項目ブラウザ内実行(CDP Runtime.evaluate)ヘッドレス直接実行(Node.js + wasm-bindgen)
実行スレッドレンダラメインスレッド(UI描画・DOMと共有)独立した Node.js プロセス
タイムアウト制約あり(クライアントツールの通信制限: 45秒〜数分で強制切断)なし(プロセスの実行完了まで無制限)
UI・描画オーバーヘッド高(DOM更新や再描画要求が割り込む)ゼロ(UI描画なし、計算に専念)
推移データ回収の確実性低(タイムアウト時にインメモリ戻り値が全喪失)確実(標準出力やファイルへ即時ストリーム保存)
50年計算の所要時間約45秒以上(タイムアウト到達)数秒以内(瞬時に完走)
適した役割画面描画の最終確認、DOM・CSS・イベント検証長期シミュレーション、大量データ試行、推移表の取得

したがって、ブラウザ自動化ですべてのシミュレーション計算を抱え込むのではなく、計算処理を Node.js に逃がし、ブラウザは最終状態の描画確認に特化させる責務分離が極めて合理的です。

wasm-bindgen によるマルチターゲット出力と Node 実行

Rust で記述されたロジックを WebAssembly にコンパイルしている場合、wasm-bindgen のマルチターゲット出力機能を用いることで、ブラウザ向けと Node.js 向けのバインディングを同一ソースから簡単に作り分けることができます。

wasm-bindgen のマルチターゲット機能

wasm-bindgen は --target オプションによって生成する JavaScript ラッパー(シム)の形式を切り替えることができます10。

  • --target web: ブラウザ環境向けの ES モジュールを出力
  • --target nodejs: Node.js 環境向けの CommonJS モジュールを出力

Rust 側の計算コアが DOM 操作やブラウザ固有のグローバルオブジェクト(web-sys など)に依存せず、純粋なステートマシンやドメインロジックとして完結していれば、同一のコンパイル済み Wasm バイナリ(wasm32-unknown-unknown)からコマンド 1 つで Node.js 向けモジュールを生成できます10。

Node.js 向けバインディングのビルド

以下のコマンドにより、リリースビルドされた Wasm バイナリから Node.js 用のバインディングを出力します。

引用元: プロジェクトA(非公開)の検証作業ログ(hands-on-log.md §3)

wasm-bindgen --target nodejs --out-dir $S/wasm-main target/wasm32-unknown-unknown/release/trillion_wasm.wasm

このコマンドにより、$S/wasm-main 配下に Node.js の require で直接ロード可能な JavaScript シムと Wasm ファイルが生成されます。実行時は、生成された Wasm モジュールのエントリポイントを環境変数 WASM で指定してスクリプトを呼び出します(例: WASM=$S/wasm-main/trillion_wasm.js node play2.js)。

台本スクリプト play2.js による推移表の取得

Node.js 環境から Wasm を直接呼び出し、ブラウザで実行したのと全く同一の施策シーケンスを適用して 5 年ごとの推移表を出力するスクリプト play2.js を作成しました。

引用元: プロジェクトA(非公開)の検証台本 play2.js(検証作業ログより抜粋)

// ブラウザで遊んだ手順と同じ施策を Wasm に直接打つ
const w = require(process.env.WASM);
const st = () => JSON.parse(w.state());
const tryDo = f => { try { f(); return true; } catch { return false; } };
const tax = m => Object.entries(m).forEach(([k, v]) => tryDo(() => w.setTaxRate(k, v)));
const half = { agriculture: 50, inn: 75, tavern: 100, smithy: 50 };
const out = [];
const snap = y => { const s = st(), p = s.population; out.push(`${y}年 人口${p.total}(子${p.childMale + p.childFemale} 成${p.adultMale + p.adultFemale} 老${p.elderMale + p.elderFemale} 冒${p.adventurers}) 収容${s.capacity.carrying} 国庫${s.treasury}`); };
snap(0);
for (let y = 1; y <= 50; y++) {
  if (y === 1 || y === 26) tax(half);
  if (y === 11) tax({ agriculture: 0, inn: 0, tavern: 0, smithy: 0 });
  for (let i = 0; i < 40; i++) {
    const s = st(); if (s.treasury < 8000) break;
    if (s.freeWallCapacity < 6) { tryDo(() => w.expandWall()); continue; }
    for (const f of ['housing', 'water', 'agriculture', 'sanitation']) tryDo(() => w.build(f, 1));
    if (y >= 26) { tryDo(() => w.build('tavern', 1)); tryDo(() => w.build('inn', 1)); }
  }
  w.advance(25);
  if (y % 5 === 0) snap(y);
}
console.log(out.join('\n'));

このスクリプトを Node.js で実行したところ、CDP の通信制約や DOM 描画負荷を受けることなく、瞬時に以下の推移ログが出力されました。

0年 人口1000(子180 成360 老360 冒100) 収容1000 国庫1000000
5年 人口841(子164 成369 老170 冒138) 収容2440 国庫454827
10年 人口809(子166 成394 老103 冒146) 収容3580 国庫66645
15年 人口847(子178 成438 老81 冒150) 収容3680 国庫6645
20年 人口891(子198 成465 老78 冒150) 収容3680 国庫6645
25年 人口922(子212 成482 老78 冒150) 収容3680 国庫6645
30年 人口964(子221 成502 老80 冒161) 収容4020 国庫88367
35年 人口1029(子237 成540 老85 冒167) 収容4520 国庫103274
40年 人口1108(子263 成583 老90 冒172) 収容5090 国庫115873
45年 人口1204(子295 成629 老99 冒181) 収容5740 国庫129877
50年 人口1300(子326 成681 老105 冒188) 収容6470 国庫146473

ブラウザ内では 45 秒でタイムアウトしていた計算が、ヘッドレスな Node.js プロセス上ではわずか数秒で完走し、欠落していた 0 年から 50 年までの 5 年刻みの推移データがすべて手に入りました。

推移表の採取とブラウザ画面描画値との終端突き合わせ

ヘッドレス環境で得られた計算結果が、本当にブラウザ画面上で動かした結果と一致しているのかを検証するため、ブラウザ画面に残っていたテキスト情報と、Node.js 実行で得られた 50 年目の数値を突合しました。

項目ブラウザ画面(50年目)Node.js 実行結果(50年目)判定
総人口1,3001,300一致
子326(男児 163 + 女児 163)326一致
成人681(成人男 349 + 成人女 332)681一致
老人105(老人男 56 + 老人女 49)105一致
冒険者188188一致
収容人口6,4706,470一致
国庫146,473146,473一致

総人口だけでなく、年齢層別の詳細内訳、収容人口、国庫残高に至るまで、すべての数値が 1 の位まで完全に一致していました。

ただし、ここで確認できたのはあくまで「50年目の最終状態の一致」である点には留意が必要です。ブラウザ内実行において 1年目から 49年目までの途中スナップショットの回収に失敗しているため、途中の各期すべてで同一の遷移を辿っていたことまでは厳密に検証できていません。

それでも、同一のシード値と施策シーケンスを与えた結果として終端値が完全に一致した事実は、ヘッドレスな推移検証スクリプトがブラウザ内の挙動を高い忠実度で再現できている強力な裏付けとなります。

検証台本のリポジトリ管理と運用の知見

この検証プロセスを通じて、検証用スクリプトの管理方法に関する重要な運用上の知見が得られました。

一時スクリプト作り捨ての反省

調査やデバッグの最中、開発者は作業用の一時ディレクトリ(scratchpad など)に play.js や play2.js といったスクリプトを作成して場当たり的に実行しがちです。

しかし、検証スクリプトを作り捨ててしまうと、後続のセッションや別の開発者が同じ検証を再現できなくなります。また、異なる施策パターン(方針 A と方針 B)による推移の比較を行いたい場合にも、スクリプトをゼロから書き直さなければならず、検証資産が組織に蓄積されません。

実行台本(playthrough)としてのコミット管理

この反省に基づき、プロジェクトAでは「プロトタイプを遊ぶ台本をコミットする(方針ごとの推移表)」という課題(Issue)が起票され、長期シミュレーションの振る舞いを確かめる台本そのものをリポジトリ内でバージョン管理する方針へと昇格しました。

一時的な scratchpad でスクリプトを作り捨てて検証を回すと、後から結果の比較や再検証ができなくなるため、長期推移を確かめる実行台本(playthrough)そのものをリポジトリにコミットして管理すべきです。

実行台本をコードベース内に固定しておけば、CI パイプラインや定期テストに組み込むことで、Wasm 側のロジック変更時に「50 年後の経済バランスが意図せず破綻していないか」をいつでも自動検証できる恒久的なテスト資産となります。

よくある質問(FAQ)

Q. CDP のタイムアウト値を延長すれば、ブラウザ内だけでも長時間のシミュレーションは完走できるのではないでしょうか?

A. タイムアウト値を引き上げれば通信切断自体は一時的に先送りできますが、根本的な解決にはなりません。シミュレーション全体の所要時間が長引くほど、DOM 更新やタイマー待機が重なってクライアントツールのセッションが不安定になり、ブラウザクラッシュのリスクも高まります。Playwright の公式ドキュメントでもテストの不安定さ(flaky)を理由にした低レベルタイムアウトの延長は避けるべきとされており、計算負荷の高い処理そのものをブラウザ外へ切り出すのが健全な設計です。

Q. ブラウザ向けにビルドされた Wasm は、どのようなプロジェクトでもそのまま Node.js で動作しますか?

A. Wasm の計算コアが DOM 操作やブラウザ専用 API(web-sys など)に依存していない純粋なロジックであれば、そのまま動作します。wasm-bindgen の --target nodejs オプションを指定することで Node.js の CommonJS モジュールとしてインポート可能なバインディングが生成されます。UI とロジックが密結合している場合は、計算に関わるステートマシンやコアエンジンを独立したクレートやモジュールとして分離しておくことが前提条件となります。

まとめ

本記事の検証から得られた知見を以下の命題にまとめます。

  • CDP 45秒タイムアウトの本質は単一コマンドの待機上限超過である: ブラウザ自動化ツール経由で長時間のシミュレーションを実行すると、非同期処理であっても全体の所要時間がクライアント側の待機上限(45秒)を超過し、通信が切断される。
  • 対症療法(タイムアウト延長・待機)ではインメモリの戻り値は救えない: 切断された evaluate 呼び出しの Promise は破棄されるため、後から待機して画面が更新されていても、途中の推移配列などは回収できない。
  • 計算と画面確認の責務を物理的に分離する: 同一の Rust ソースから wasm-bindgen --target nodejs でヘッドレス実行環境を構築し、重い試行や推移取得は Node.js で高速に完走させる。
  • 画面描画値との終端突き合わせで正しさを証明する: Node.js で算出した終端値(最終期の値)をブラウザ画面の描画値と照合することで、ブラウザ描画の健全性と計算ロジックの一致を両立して担保する。
  • 実行台本は作り捨てずリポジトリにコミットする: 一時的なスクリプトではなく、長期シミュレーションを再現可能な台本としてリポジトリで管理し、継続的な品質検証資産とする。

Web アプリケーションの複雑な検証自動化や、Wasm・AI エージェントを組み合わせたテスト環境の設計・改善について、ぜひ コンタクトフォーム よりご相談ください。

Footnotes

  1. Chrome DevTools Protocol: js_protocol.pdl (Runtime.evaluate) ChromeDevTools, 2026年確認。command evaluate のパラメータ仕様(expression, awaitPromise, returnByValue, timeout)の定義根拠。 ↩

  2. Chrome for Developers: browser.debugger API Google Chrome Team, 2026年確認。chrome.debugger API の sendCommand トランスポート仕様、拡張機能からの CDP デバッグセッション確立。 ↩

  3. The Chromium Projects: Multi-process Architecture The Chromium Authors, 2026年確認。Chromium における Browser Process と Renderer Process の物理分離、IPC 通信モデルのアーキテクチャ根拠。 ↩

  4. Chromium Source: Task scheduling in Blink Blink Project Authors, 2026年確認。Blink において JavaScript 実行が単一のメインスレッド上で不可分(atomic unit)のタスクとしてスケジューリングされる仕様根拠。 ↩

  5. Anthropic Claude Code Issue #101443: javascript_tool timeout side effect Claude Code Issue, 2026年確認。javascript_tool で 45000ms タイムアウトが発生した後もバックグラウンドで処理が完了する「副作用の非同期完走」の実例。 ↩

  6. 筆者の開発検証ログ(非公開) 2026年10月実施、当リポジトリの一次検証ログ。50年シミュレーション実行時の CDP 45秒タイムアウトログ、10秒待機後の画面テキスト、Node.js 実行による推移表、画面描画値との終端突き合わせ、および台本コミットへの昇格記録(hands-on-log.md §1〜§5)。 ↩

  7. Puppeteer: ConnectOptions interface (protocolTimeout) Puppeteer Team, 2026年確認。CDP コマンド呼び出しのタイムアウトを設定する protocolTimeout プロパティの仕様(デフォルト180,000ms)。 ↩

  8. Puppeteer Issue #12723: Runtime.callFunctionOn timed out Puppeteer Issue, 2024年報告(2026年確認)。CDP コールタイムアウト時の事象例と、対症療法としての protocolTimeout 引き上げの言及。 ↩

  9. Playwright: Timeouts Microsoft, 2026年確認。テストの不安定さ(flaky)への対処として低レベルタイムアウトの引き上げに依存することを戒める設計指針。 ↩

  10. The wasm-bindgen Guide: Deployment (—target) The wasm-bindgen Guide, 2026年確認。--target nodejs により CommonJS モジュール用の JS シムを生成し require で読み込める仕様根拠。 ↩ ↩2