Kitsune Gadget

気になったことをつらつらと

Chrome拡張機能で起きるメッセージングエラー

Chrome拡張機能の開発において、バックグラウンドとコンテンツスクリプト間のやりとり等は Web Extensions API をベースとした Chrome Extensions API のメッセージングを利用したものです。その際に起こり得る2つのエラーを詳細と共にまとめます。 他の記事ではこれを書いておけば回避できるようなものしか見つけられなかったので、 これらのエラーがどのように起きるかを調査しました。

The message port closed before a response was received

このエラーは、メッセージを送信したが、レスポンスが帰ってくる前にポートが閉じられた場合に表示されます。

必要なくてもレスポンスを返すようにする

chrome.runtime.sendMessagechrome.tabs.sendMessageなどのメッセージングは、 呼び出されるとレスポンス待ち状態になります。 レスポンスが返される前にポートが閉じられると、このエラーが起きます。

これを防ぐにはレスポンスが必要なくても、受信側で必ずレスポンスを返しておくのが望ましいです。

return true を返すことの意味

送信先すべてのonMessageリスナーを処理して何も返されないまま最後まで到達すると、同期処理としてメッセージポートがそのまま閉じられてしまいます。 しかしながら、処理の最後にreturn trueを返すと、ポートをすぐ閉じずにレスポンスを待つよう送信元に伝えることができます。これによって、送信先からのレスポンスを非同期で扱えるようになります。

sendMessage の書き方の違い

Chrome Extensions API では今のところ、コールバックを含ませるか省略するかで返り値が違います。コールバックを省略した書き方は、マニフェストV3以上で扱う事ができます。

コールバックを省略する場合

マニフェストV3から実装された書き方です。今後はこちらが標準になるでしょう。

コールバックを省略する場合、sendMessageの返り値はPromiseです。送信先onMessageリスナーにsendResponsereturn trueが存在しないとこのエラーが起こります。

return trueのみではエラーが発生しないものの、レスポンスが返されないとPromiseの<pending>状態が続いてしまいます。この状態で送信先ページが終了したり、拡張機能の再ロードを行うとポートが強制的に閉じられてエラーを返します。

const obj = {
  // なにかしらの送りたいメッセージオブジェクト
}

const result = chrome.runtime.sendMessage(obj); 
// コールバックを省略しているため、返り値は Promise

console.log(result); // Promise オブジェクト

result.then(r => {
  console.log(r); // sendResponse が返されれば resolve されます
}).catch(e => {
  console.error(e); 
});
// 送信先と接続できなかったり、ポートが閉じた場合や、
// sendResponse が帰らなかった場合にエラーが発生し reject されます
コールバックを含める場合

こちらはマニフェストV3以前から使われていた書き方です。

引数にコールバックがある場合は返り値が無いためundefinedとなります。こちらも同様に、送信先リスナーにsendResponseもしくはreturn trueのどちらかが無いとこのエラーが起こります。

コールバックの処理はポートが閉じる、またはレスポンスが返されたタイミングに発生します。

ただし、return trueがある場合はコールバックを含めない場合と同様にレスポンス待ち状態になるため、sendResponseが帰らないまま送信先が終了したり、再ロード等するとエラーが発生します。

const resultCallback = chrome.runtime.sendMessage(obj, (result) => {
  console.log(result);
});
// 受信側に return true があるときは非同期待ちになり、
// sendResponse が返されたタイミングでコールバックが実行されます

// sendResponse が帰らない等のエラーが発生した場合は、
// コールバックを実行してから該当のエラーを返します

console.log(resultCallback); // undefined
onMessage 側の例

onMessageリスナーにおいてfetchsetTimeoutなどの非同期処理の利用で、後からsendResponseを返すことがわかっている場合、return trueを返しておくことで必要なレスポンスを待つことができるようになります。

sendMessageがコールバックを含む、含まないどちらの実装でもsendResponseで返信するため、sendResponseを置くことは忘れずにしましょう。

// 同期的に返信する場合
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
    
    // 実行したい処理 //

    sendResponse("Response."); // 最後にsendResponse を必ず返しておく
  }
});
// 非同期で返信する場合、setTimeoutで返信を5秒後に行う例。
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
    setTimeout(() => {
      sendResponse("Asynchronous response."); // sendResponse は必ず返す
    }, 5000);

    return true; // 非同期で返信する場合は return true を先に返しておく
  }
});

複数の onMessage リスナーにおけるレスポンスについて

Note: If multiple pages are listening for onMessage events, only the first to call sendResponse() for a particular event will succeed in sending the response. All other responses to that event will be ignored.

Chrome Extensions Message passing - Chrome for Developers

上記に書いてある通り、送信先に複数のonMessageリスナーが存在する場合、sendMessageは初めにレスポンスを受け取ったものしか扱えません。ただし、すべてのonMessageリスナーの内容は通って来ます。 もし、条件分岐をしているが、すべての場所を抜けて受け取るレスポンスが最後までない場合に、これらのどこかでレスポンスを返しておく必要が出てきます。逆に言えば、条件分岐でどこも通らずreturn trueやレスポンスを返さなければエラーを出力できることになります。

送信メッセージの対象を限定しておくべきです。受け取るメッセージの順番が保証されてないという理由もあります。 一度に2つ以上のonMessageリスナーに反応させたいとするとこういう問題に出くわすでしょう。 下記の例のように個別にそれぞれ送信するのが望ましいです。さらに、個別にレスポンスを受け取れるため見通しが良くなります。

伝送を単体ごとで確実にしたいのならコネクションべースのメッセージも利用できます。

複数対象へのメッセージング例

sw.js バックグラウンド

const obj1 = {
  type: "swMessageTo1",
  data: "sendMessage from sw."
}

const obj2 = {
  type: "swMessageTo2",
  data: "sendMessage from sw."
}

chrome.tabs.sendMessage(sender.tab.id, obj1).then(result => {
  console.log(result);
}).catch(e => {
  conosle.error(e);
});

chrome.tabs.sendMessage(sender.tab.id, obj2).then(result => {
  console.log(result);
}).catch(e => {
  conosle.error(e);
});

cs1.js コンテンツスクリプト

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === "swMessageTo1") {
    console.log("onMessage in cs1.");
    sendResponse("response from cs1.");
  }
});

cs2.js コンテンツスクリプト

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === "swMessageTo2") {
    console.log("onMessage in cs2.");
    sendResponse("response from cs2.");
  }
});

出力ログ

バックグラウンドページ

response from cs1.
response from cs2.

コンテンツページ

onMessage in cs1.
onMessage in cs2.

Could not establish connection. Receiving end does not exist

こちらのエラーは送信先と接続が確立できなかったことを表しています。

バックグラウンドからコンテンツスクリプト側への送信で発生する可能性があります。 複数のコンテンツスクリプトにまたがってメッセージングをする場合、 もしくは Action でバックグラウンドからコンテンツスクリプトに情報を送る場合などが考えられます。

また、拡張機能を再読み込みしたあと、コンテンツスクリプトのあるページをリロードする前にメッセージングしようとした場合にも接続ができずにこのエラーを発生させます。

今回は[ポップアップ] → [バックグラウンド] → [コンテンツスクリプト] のようなやり取りのケースを考えてみます。

設定したページで[ポップアップ]からメッセージを送信すると、[バックグラウンド]が起動、[コンテンツスクリプト]へメッセージを送信しようとします。 ところが、[コンテンツスクリプト]スクリプトが実行されてない状態(ロード中)だった場合はonMessageがどこにも存在しておらず、このエラーを起こします。

タブの status が complete のときに送信する

対処としては、送信先タブのstatuscompleteのときに送信させるようにします。

complete状態のタブはすべてのコンテンツが読み込まれた状態を表します。つまり、window.onloadが発火された後ともいえます。

ところで、ポップアップを実行した時にページに埋め込んだコンテンツスクリプトがないと、[バックグラウンド][コンテンツスクリプト]の送信は失敗します。このような場合は、Action の実行が利用したいページのみにマッチするように declarativeContent を実装しておくと良いです。

実装例

popup.html

<!DOCTYPE html>
<head>
    <script defer src="./popup.js"></script>
</head>
<body>
    <button id="send-button">Click send message.</button>
</body>
</html>

popup.js

window.addEventListener("load", () => {
  const button = document.getElementById("send-button");

  button.addEventListener("click", async () => {
    const tabs = await chrome.tabs.query({
      active: true,
      currentWindow: true,
    });

    if (tabs.length > 0) {
      const result = await chrome.runtime.sendMessage({
        type: "fromPopupMessage",
        data: "Send from popup.",
        sendTabId: tabs[0].id,
      });

      if (result) {
        console.log(result);
      }
    } else {
      console.error("Could not get currentTab.");
    }
  });
});

sw.js バックグラウンド

const sendMessage = async (tabId, obj, time) => {
  const tab = await chrome.tabs.get(tabId);

  if (tab && tab.status === "complete") { 
    // tab のステータスが complete のときに実行
    const result = await chrome.tabs.sendMessage(tabId, obj).catch((err) => {
      console.error(err);
      return "Send background to contentScript is error.";
    });

    if (result) {
      return result;
    }
  } else if (time <= 1600) {
    console.log("Retry sendMessage...");

    return "RETRY";
  } else {
    return console.error("Timeout: Tab status not complete.");
  }
};

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === "fromPopupMessage") {
    console.log(message.data);

    const obj = {
      data: "sendMessage from sw.",
    };

    let time = 50;
    const wrapSendMessage = async () => {
      const result = await sendMessage(message.sendTabId, obj, time);

      if (result === "RETRY") {
        time += time;
        setTimeout(wrapSendMessage, time);
      } else {
        sendResponse(result);
      }
    };

    wrapSendMessage();

    return true; // ポートを継続させて送信元をレスポンス待ちにする
  }

  sendResponse("Could not connect.");
});

// 特定のページのみで Action を実行できるようにします
// manifest の permission に "declarativeContent" が必要です
chrome.runtime.onInstalled.addListener((detials) => {
  chrome.action.disable(); 

  chrome.declarativeContent.onPageChanged.removeRules(undefined, () => {
    const rule = {
      conditions: [
        new chrome.declarativeContent.PageStateMatcher({
          pageUrl: {
            hostSuffix: ".google.com", 
            // 例として .google.com ホスト下のページでアクティブになります
            schemes: ["https"],
          },
        }),
      ],
      actions: [new chrome.declarativeContent.ShowAction()],
    };

    chrome.declarativeContent.onPageChanged.addRules([rule]);
  });

   // ページを切り替えたときに条件にマッチしない場合は
   //  action が disable のままとなります。
});

setTimeoutの繰り返しで行いました。他に良い方法があるかもしれません。 return trueの効果によりレスポンスを非同期で待ち続け、時間差でレスポンスを返信することができます。

cs.js コンテンツスクリプト

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  sendResponse("Content script: response ok.");
});

出力ログ

長くなってしまったので、popup 側のコンソールのみを見てみます。 なぜかバックグラウンドのログも取得してくるので割りとごちゃっとしますが…。

Send from popup.      ... sw.js
Content script: response ok.          ...popup.js

次に、Network Throttling を Slow 3G にして擬似的にロード時間を遅くして実行してみます。

Send from popup.          ...sw.js
Retry sendMessage...           ...sw.js
Retry sendMessage...           ...sw.js
Retry sendMessage...           ...sw.js
Retry sendMessage...           ...sw.js
Retry sendMessage...           ...sw.js
Retry sendMessage...           ...sw.js
Content script: response ok.           ...popup.js

リトライは最大6回なので、ギリギリロードが完了して実行できたようです。

Send from popup.          ...sw.js
Retry sendMessage...           ...sw.js
Retry sendMessage...           ...sw.js
Retry sendMessage...           ...sw.js
Retry sendMessage...           ...sw.js
Retry sendMessage...           ...sw.js
Retry sendMessage...           ...sw.js
Timeout: Tab status not complete.          ...sw.js

タイムアウトが起こる場合はこんな感じです。

ちなみに、マニフェストV3よりバックグラウウンドはサービスワーカーとなったため、永続性が保証されません。 https://developer.chrome.com/docs/extensions/mv3/migrating_to_service_workers/#alarms

こちらではsetTimeoutのかわりに Alarms API の利用も推奨されていますが、 Alarms API は1分未満の間隔では動かない制限がなされており、バックグラウンドが長く存在できない1分以上でタイムアウトしたい場合には有効です。 短時間の処理にはsetTimeoutでもバックグラウンドが終了する前に完了できるため、今回は問題はありません。

onMessage のリスナーに async を使わない!

developer.mozilla.org

MDNの文章では、「addListenerasync関数を使って実行しないでください。」と書いてあります。

実際、onMessage.addListenerのコールバックをasyncにした場合、return trueを書いたとしてもポートがすぐに閉じられてしまいます。これは結局のところ同期的なsendResponseにしか応答できなくなってしまうことを意味します。

複数のonMessageがあるコンテンツスクリプトの一部でこのようなasyncが使われていると、同期的なsendResponseを返すしかなくなるため、asyncされたonMessageを途中で通ると、そこで応答を必ず返さなければならない状態になります。

回避策

コールバック中でasyncの関数式により回避はできます。 もしくはasyncを利用せずにPromiseのチェーンで解決させます。

関数式で回避

chrome.runtime.onMessage.addListner((message, sender, sendResponse) => {
  const wrapFunction = async () => {
    const tabs = await chrome.tabs.get(sender.tab.id).catch(err => {
      sendResponse("Error");
      console.error(err);
    });
    
    if (tabs) {
      sendResponse(tabs);
    }
  };

  wrapFunction();
  return true;
});

Promise チェーンで回避

chrome.runtime.onMessage.addListner((message, sender, sendResponse) => {
  chrome.tabs.get(sender.tab.id).then(r => {
    sendResponse(r);
  }).catch(err => {
    sendResponse("Error");
    console.error(err);
  });

  return true;
});

Promise チェーンで回避: チェーン内部ではasyncを利用可能

chrome.runtime.onMessage.addListner((message, sender, sendResponse) => {
  chrome.tabs.get(sender.tab.id).then(async (r) => {
    const window = await chrome.windows.getCurrentWindow();
    sendResponse(window);
  }).catch(err => {
    sendResponse("Error");
    console.error(err);
  });

  return true;
});

まとめ

メッセージングのエラーが起きる原因は、メッセージポートがすぐに閉じてしまったり、送信先が存在しないなど。不意の設計エラーであることがほとんどでした。単純な問題でも意外な落とし穴に嵌まってしまうものです。

Web Extension のメッセージングは今後Promiseへと完全移行し、sendResponseを削除することを予定しているそうです。 よって、レスポンスをsendResponseではなくretrun Promise.resolve等で返すこともできるみたいですが、Chrome拡張機能では今のところ対応していないと思われます。移行が進めばsendResponseの代わりにPromise.resolvePromise.rejectなどで応答を返すことになるのでしょうか。

参考