環境
- Chrome 120以降 / Manifest V3
- TypeScript 5.3 + esbuild
- 状態管理:
chrome.storage.local+ コンテキスト内グローバル変数 - 進捗表示: popup の
setIntervalによる 500ms ポーリング - 収集処理: content script 内で実行される非同期ループ
バグの概要
収集処理の進捗表示UIを後付けするため、状態を chrome.storage.local に永続化し、popup 側から setInterval でポーリングするようにした。すると「同じ処理が無限ループし続ける」というP1バグが発生した。
原因はシンプルで、稼働中の非同期処理が、以前読み込んだ古い状態を元にストレージへ上書き保存していたことにある。私は「ストレージ=最新状態」と思い込んでいたが、ストレージはあくまで「最後に書いた人の値」であり、最新とは限らない。これはDBでいうところの Lost Update(失われた更新)と同種の問題である。
実装の流れ
まず、既存の収集処理はメモリ上のフラグで排他制御していた。
// content script(移行前)
let running = false;
async function collect() {
if (running) return;
running = true;
try {
const items = await fetchItems();
for (const item of items) {
await processItem(item);
}
} finally {
running = false;
}
}
この状態で進捗UIを追加するため、状態を chrome.storage.local に書き出すようにした。
// content script(移行後)
let state = {
phase: 'idle',
progress: 0,
iteration: 0,
};
async function saveState() {
await chrome.storage.local.set({ state });
}
async function collect() {
if (state.phase === 'running') return;
state.phase = 'running';
state.iteration++;
await saveState(); // 開始を保存
try {
const items = await fetchItems();
for (const item of items) {
await processItem(item);
state.progress++;
await saveState(); // 進捗を保存
}
} finally {
state.phase = 'idle';
await saveState(); // 完了を保存
}
}
一方、popup は以下のようにポーリングする。
// popup.ts
const timer = setInterval(async () => {
const result = await chrome.storage.local.get('state');
const state = result.state;
if (state) {
renderProgress(state.progress, state.iteration);
}
}, 500);
一見、これで問題なく動きそうに見える。実際、収集処理が停止している状態でポーリングするだけなら問題は起きない。
実際に起きたこと
収集処理が稼働中にpopupを開き、500ms間隔でポーリングすると、以下のシーケンスで状態が巻き戻る。
- 収集処理開始
state.phase = 'running'をストレージに保存 - popup がストレージを読んで
phase: 'running'を表示 - 収集処理が1件処理し
state.progress = 1で保存 -
processItem内の別の非同期処理(API呼び出し等)が完了したタイミングで、古いstateスナップショットを参照して保存するコードが走る - ストレージ上の
stateが古い値(progress: 0やphase: 'idle')に戻る - popup が「停止中」と表示
- ユーザーが再度実行をクリック → 実際は
phase: 'running'の処理が動いているのに、ストレージ上はidleなので同じ処理が再実行される
これが無限ループの正体である。特に厄介なのは、chrome.storage.local.set は非同期であり、書き込み完了順が呼び出し順と一致しない点だ。set を連発すると、後から呼んだ set が先に完了し、先に呼んだ set が後から上書きされる可能性もある。
なぜ「ストレージ=最新」と思い込んだのか
振り返ると、私は以下の前提を暗黙に置いていた。
- ストレージに書いた値は「その時点での最新状態」である
- 読み出せば常に最新の状態が得られる
- content script 内は単一コンテキストなので競合しない
しかし実際は、ストレージは共有バスであり、複数コンテキスト(popup / content script / service worker)から同時に読み書きされる。読み出してから書き込むまでの間に、別のコンテキストが同じキーを更新していれば、後勝ちで上書きされる。
対策
対策は「稼働中は共有メモリ上の状態だけを信頼し、停止中のみストレージから再読込する」というガードを追加することだ。
具体的には、以下の3点を徹底する。
- 実行中の状態はメモリ内のグローバル変数のみを正とする
- ストレージへの保存は「実行開始時」と「実行終了時」の2回だけ行う
- ポーリング側は「停止中」のときだけストレージを読み、実行中はメモリ上の値を参照する
対策後のコード
// content script(対策後)
let state = {
phase: 'idle',
progress: 0,
};
// 実行中の状態はメモリのみ。ストレージには開始/終了だけ書く。
async function persist() {
await chrome.storage.local.set({ state });
}
export async function startCollect() {
const stored = await chrome.storage.local.get('state');
if (stored.state && stored.state.phase === 'running') {
throw new Error('既に実行中');
}
state = { phase: 'running', progress: 0 };
await persist(); // 開始のみ保存
try {
const items = await fetchItems();
for (const item of items) {
await processItem(item);
state.progress++;
// ここではストレージ保存しない(メモリのみ更新)
}
} finally {
state.phase = 'idle';
await persist(); // 終了のみ保存
}
}
// popup(対策後)
async function poll() {
const result = await chrome.storage.local.get('state');
const storedState = result.state;
if (!storedState || storedState.phase === 'idle') {
renderProgress(storedState?.progress ?? 0);
return;
}
// 実行中の詳細な進捗は content script のメモリから取得する
const response = await chrome.tabs.sendMessage(tabId, { type: 'GET_STATE' });
renderProgress(response.state.progress);
}
比較表
| 観点 | ストレージ基準 | メモリ基準 |
|---|---|---|
| 鮮度 | 他コンテキストからは読めるが「最後に書いた人」の値 | 同一コンテキスト内では常に最新 |
| 永続性 | ブラウザ再起動後も残る | コンテキスト破棄で消える |
| 競合 | read-modify-write で Lost Update が起きる | JavaScript は単一スレッドなので排他が簡単 |
| 実行中の管理 | 不向き | 最適 |
| 停止後の保存 | 必須 | 不要(永続化する場合は結局必要) |
結論としては「実行中はメモリ、停止中はストレージ」が安全である。
FAQ
Q. chrome.storage.session を使えば良かったのでは?
A. chrome.storage.session はメモリ上のみで永続化されないため、競合は減るが、同じキーを複数コンテキストが書き込む構造なら Lost Update は起きる。根本解決にはならない。「実行中はメモリのみ」という原則は変わらない。
Q. なぜ進捗のたびに chrome.storage.local.set を呼んではいけないのか?
A. 非同期の set は完了順が保証されないため、古い進捗が新しい進捗を上書きする可能性がある。また、popup がポーリングしている最中に content script が古いスナップショットから書き戻せば、状態が巻き戻る。実行中の進捗はメモリに置き、表示側からメッセージで取得するのが安全。
Q. Service Worker を経由して一元管理するほうが良いのでは?
A. MV3 の Service Worker はアイドルで破棄されるため、メモリ状態の保持は保証されない。結局、「実行中はメモリ、停止時はストレージ」という設計が最も素直で、バグを生みにくい。
まとめ
- ストレージは「最新」ではなく「最後に書いた人の値」である
- ポーリングUIを追加するときは、読み書きの競合まで想定する
- 既存実装が安全だったのは、単に誰も稼働中にポーリングしていなかっただけ
- 状態管理の基準を「メモリ vs ストレージ」で明確に分け、実行中はメモリだけを信頼する
あのときは「状態を確認しただけで、同じ処理が無限ループし続けた」と驚いたが、原因はストレージを正とする設計にあった。エンジニアの皆さんは、状態管理にストレージとメモリのどちらを基準にしていますか?
この記事を書いた人
BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。
GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。
👉 BENTEN Web Works — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中