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?

Chrome拡張(MV3)で「ストレージが最新」という思い込みがP1バグを生んだ話

0
Posted at

環境

  • 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間隔でポーリングすると、以下のシーケンスで状態が巻き戻る。

  1. 収集処理開始 state.phase = 'running' をストレージに保存
  2. popup がストレージを読んで phase: 'running' を表示
  3. 収集処理が1件処理し state.progress = 1 で保存
  4. processItem 内の別の非同期処理(API呼び出し等)が完了したタイミングで、古い state スナップショットを参照して保存するコードが走る
  5. ストレージ上の state が古い値(progress: 0phase: 'idle')に戻る
  6. popup が「停止中」と表示
  7. ユーザーが再度実行をクリック → 実際は phase: 'running' の処理が動いているのに、ストレージ上は idle なので同じ処理が再実行される

これが無限ループの正体である。特に厄介なのは、chrome.storage.local.set は非同期であり、書き込み完了順が呼び出し順と一致しない点だ。set を連発すると、後から呼んだ set が先に完了し、先に呼んだ set が後から上書きされる可能性もある。

なぜ「ストレージ=最新」と思い込んだのか

振り返ると、私は以下の前提を暗黙に置いていた。

  • ストレージに書いた値は「その時点での最新状態」である
  • 読み出せば常に最新の状態が得られる
  • content script 内は単一コンテキストなので競合しない

しかし実際は、ストレージは共有バスであり、複数コンテキスト(popup / content script / service worker)から同時に読み書きされる。読み出してから書き込むまでの間に、別のコンテキストが同じキーを更新していれば、後勝ちで上書きされる。

対策

対策は「稼働中は共有メモリ上の状態だけを信頼し、停止中のみストレージから再読込する」というガードを追加することだ。

具体的には、以下の3点を徹底する。

  1. 実行中の状態はメモリ内のグローバル変数のみを正とする
  2. ストレージへの保存は「実行開始時」と「実行終了時」の2回だけ行う
  3. ポーリング側は「停止中」のときだけストレージを読み、実行中はメモリ上の値を参照する

対策後のコード

// 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) — 日々の知見を発信中

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?