Bug 1479740 - Track Web API calls made in the child - r?kmag draft
authorTarek Ziadé <tarek@mozilla.com>
Tue, 14 Aug 2018 12:37:51 +0200
changeset 828889 45d577eddb838e16ab121877eb40b8f401b6a055
parent 828887 8b39d1161075364a95bc2d1577b389411fe5c342
push id118726
push usertziade@mozilla.com
push dateTue, 14 Aug 2018 10:40:59 +0000
reviewerskmag
bugs1479740
milestone63.0a1
Bug 1479740 - Track Web API calls made in the child - r?kmag The performance counter is now also used in the children, and the ParentAPIManager.retrievePerformanceCounters() can be used to aggregate all counters into a promise. MozReview-Commit-ID: KqzMVfdOTYP
modules/libpref/init/all.js
toolkit/components/extensions/ExtensionChild.jsm
toolkit/components/extensions/ExtensionParent.jsm
toolkit/components/extensions/PerformanceCounters.jsm
toolkit/components/extensions/moz.build
toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.js
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -5001,16 +5001,21 @@ pref("extensions.webextensions.backgroun
 pref("extensions.webextensions.ExtensionStorageIDB.enabled", true);
 #else
 pref("extensions.webextensions.ExtensionStorageIDB.enabled", false);
 #endif
 
 // if enabled, store execution times for API calls
 pref("extensions.webextensions.enablePerformanceCounters", false);
 
+// Maximum age in milliseconds of performance counters in children
+// When reached, the counters are sent to the main process and
+// reset, so we reduce memory footprint.
+pref("extensions.webextensions.performanceCountersMaxAge", 0);
+
 // Report Site Issue button
 pref("extensions.webcompat-reporter.newIssueEndpoint", "https://webcompat.com/issues/new");
 #if defined(MOZ_DEV_EDITION) || defined(NIGHTLY_BUILD)
 pref("extensions.webcompat-reporter.enabled", true);
 #else
 pref("extensions.webcompat-reporter.enabled", false);
 #endif
 
--- a/toolkit/components/extensions/ExtensionChild.jsm
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -19,20 +19,22 @@ var EXPORTED_SYMBOLS = ["ExtensionChild"
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this, "finalizationService",
                                    "@mozilla.org/toolkit/finalizationwitness;1",
                                    "nsIFinalizationWitnessService");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
+  DeferredTask: "resource://gre/modules/DeferredTask.jsm",
   ExtensionContent: "resource://gre/modules/ExtensionContent.jsm",
   ExtensionPageChild: "resource://gre/modules/ExtensionPageChild.jsm",
   MessageChannel: "resource://gre/modules/MessageChannel.jsm",
   NativeApp: "resource://gre/modules/NativeMessaging.jsm",
+  PerformanceCounters: "resource://gre/modules/PerformanceCounters.jsm",
   PromiseUtils: "resource://gre/modules/PromiseUtils.jsm",
 });
 
 XPCOMUtils.defineLazyGetter(
   this, "processScript",
   () => Cc["@mozilla.org/webextensions/extension-process-script;1"]
           .getService().wrappedJSObject);
 
@@ -850,16 +852,68 @@ class ProxyAPIImplementation extends Sch
   }
 
   hasListener(listener) {
     let map = this.childApiManager.listeners.get(this.path);
     return map.listeners.has(listener);
   }
 }
 
+class ChildLocalAPIImplementation extends LocalAPIImplementation {
+  constructor(pathObj, name, childApiManager) {
+    super(pathObj, name, childApiManager.context);
+    this.childApiManager = childApiManager;
+    this._purger = null;
+  }
+
+  sendPerformanceCounter() {
+    if (!this.childApiManager.context.active) {
+      return;
+    }
+    let options = {childId: this.childApiManager.id,
+                   counters: PerformanceCounters.flush()};
+    Services.cpmm.sendAsyncMessage("Extension:SendPerformanceCounter", options);
+  }
+
+  withTiming(callable) {
+    if (!PerformanceCounters.enabled) {
+      return callable();
+    }
+    let start = Cu.now();
+    try {
+      return callable();
+    } finally {
+      let end = Cu.now();
+      PerformanceCounters.storeExecutionTime(this.context.extension.id, this.name, end - start);
+      // If we store a performance counter for the first time, we create this._purger.
+      // If this._purger already exists and is not armed, we arm it.
+      if (!this._purger) {
+        this._purger = new DeferredTask(() => {
+          this.sendPerformanceCounter();
+        }, PerformanceCounters.maxAge);
+        this._purger.arm();
+      } else if (!this._purger.isArmed) {
+        this._purger.arm();
+      }
+    }
+  }
+
+  callFunction(args) {
+    return this.withTiming(() => super.callFunction(args));
+  }
+
+  callFunctionNoReturn(args) {
+    return this.withTiming(() => super.callFunctionNoReturn(args));
+  }
+
+  callAsyncFunction(args, callback, requireUserInput) {
+    return this.withTiming(() => super.callAsyncFunction(args, callback, requireUserInput));
+  }
+}
+
 // We create one instance of this class for every extension context that
 // needs to use remote APIs. It uses the message manager to communicate
 // with the ParentAPIManager singleton in ExtensionParent.jsm. It
 // handles asynchronous function calls as well as event listeners.
 class ChildAPIManager {
   constructor(context, messageManager, localAPICan, contextData) {
     this.context = context;
     this.messageManager = messageManager;
@@ -1067,17 +1121,17 @@ class ChildAPIManager {
     return true;
   }
 
   getImplementation(namespace, name) {
     this.apiCan.findAPIPath(`${namespace}.${name}`);
     let obj = this.apiCan.findAPIPath(namespace);
 
     if (obj && name in obj) {
-      return new LocalAPIImplementation(obj, name, this.context);
+      return new ChildLocalAPIImplementation(obj, name, this);
     }
 
     return this.getFallbackImplementation(namespace, name);
   }
 
   getFallbackImplementation(namespace, name) {
     // No local API found, defer implementation to the parent.
     return new ProxyAPIImplementation(namespace, name, this);
--- a/toolkit/components/extensions/ExtensionParent.jsm
+++ b/toolkit/components/extensions/ExtensionParent.jsm
@@ -23,16 +23,17 @@ XPCOMUtils.defineLazyModuleGetters(this,
   AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
   DeferredTask: "resource://gre/modules/DeferredTask.jsm",
   E10SUtils: "resource://gre/modules/E10SUtils.jsm",
   ExtensionData: "resource://gre/modules/Extension.jsm",
   MessageChannel: "resource://gre/modules/MessageChannel.jsm",
   MessageManagerProxy: "resource://gre/modules/MessageManagerProxy.jsm",
   NativeApp: "resource://gre/modules/NativeMessaging.jsm",
   OS: "resource://gre/modules/osfile.jsm",
+  PerformanceCounters: "resource://gre/modules/PerformanceCounters.jsm",
   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
   Schemas: "resource://gre/modules/Schemas.jsm",
 });
 
 XPCOMUtils.defineLazyServiceGetters(this, {
   aomStartup: ["@mozilla.org/addons/addon-manager-startup;1", "amIAddonManagerStartup"],
 });
 
@@ -55,17 +56,16 @@ var {
   promiseEvent,
   promiseObserved,
 } = ExtensionUtils;
 
 const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json";
 const CATEGORY_EXTENSION_MODULES = "webextension-modules";
 const CATEGORY_EXTENSION_SCHEMAS = "webextension-schemas";
 const CATEGORY_EXTENSION_SCRIPTS = "webextension-scripts";
-const TIMING_ENABLED_PREF = "extensions.webextensions.enablePerformanceCounters";
 
 let schemaURLs = new Set();
 
 schemaURLs.add("chrome://extensions/content/schemas/experiments.json");
 
 let GlobalManager;
 let ParentAPIManager;
 let ProxyMessenger;
@@ -502,27 +502,37 @@ GlobalManager = {
   init(extension) {
     if (this.extensionMap.size == 0) {
       ProxyMessenger.init();
       apiManager.on("extension-browser-inserted", this._onExtensionBrowser);
       this.initialized = true;
     }
 
     this.extensionMap.set(extension.id, extension);
+    Services.ppmm.addMessageListener("Extension:SendPerformanceCounter", this);
   },
 
   uninit(extension) {
+    Services.ppmm.removeMessageListener("Extension:SendPerformanceCounter", this);
     this.extensionMap.delete(extension.id);
 
     if (this.extensionMap.size == 0 && this.initialized) {
       apiManager.off("extension-browser-inserted", this._onExtensionBrowser);
       this.initialized = false;
     }
   },
 
+  async receiveMessage({name, data}) {
+    switch (name) {
+      case "Extension:SendPerformanceCounter":
+        PerformanceCounters.merge(data.counters);
+        break;
+    }
+  },
+
   _onExtensionBrowser(type, browser, additionalData = {}) {
     browser.messageManager.loadFrameScript(`data:,
       Components.utils.import("resource://gre/modules/Services.jsm");
 
       Services.obs.notifyObservers(this, "tab-content-frameloader-created", "");
     `, false);
 
     let viewType = browser.getAttribute("webextension-view-type");
@@ -744,30 +754,26 @@ class DevToolsExtensionPageContextParent
 
     this._devToolsToolbox = null;
 
     super.shutdown();
   }
 }
 
 ParentAPIManager = {
-  // stores dispatches counts per web extension and API
-  performanceCounters: new DefaultMap(() => new DefaultMap(() => ({duration: 0, calls: 0}))),
-
   proxyContexts: new Map(),
 
   init() {
     Services.obs.addObserver(this, "message-manager-close");
 
     Services.mm.addMessageListener("API:CreateProxyContext", this);
     Services.mm.addMessageListener("API:CloseProxyContext", this, true);
     Services.mm.addMessageListener("API:Call", this);
     Services.mm.addMessageListener("API:AddListener", this);
     Services.mm.addMessageListener("API:RemoveListener", this);
-    XPCOMUtils.defineLazyPreferenceGetter(this, "_timingEnabled", TIMING_ENABLED_PREF, false);
   },
 
   attachMessageManager(extension, processMessageManager) {
     extension.parentMessageManager = processMessageManager;
   },
 
   async observe(subject, topic, data) {
     if (topic === "message-manager-close") {
@@ -875,33 +881,32 @@ ParentAPIManager = {
   closeProxyContext(childId) {
     let context = this.proxyContexts.get(childId);
     if (context) {
       context.unload();
       this.proxyContexts.delete(childId);
     }
   },
 
-  storeExecutionTime(webExtensionId, apiPath, duration) {
-    let apiCounter = this.performanceCounters.get(webExtensionId).get(apiPath);
-    apiCounter.duration += duration;
-    apiCounter.calls += 1;
+  async retrievePerformanceCounters() {
+    // getting the parent counters
+    return PerformanceCounters.getData();
   },
 
   async withTiming(data, callable) {
-    if (!this._timingEnabled) {
+    if (!PerformanceCounters.enabled) {
       return callable();
     }
     let webExtId = data.childId.split(".")[0];
     let start = Cu.now();
     try {
       return callable();
     } finally {
       let end = Cu.now();
-      this.storeExecutionTime(webExtId, data.path, end - start);
+      PerformanceCounters.storeExecutionTime(webExtId, data.path, end - start);
     }
   },
 
   async call(data, target) {
     let context = this.getContextById(data.childId);
     if (context.parentMessageManager !== target.messageManager) {
       throw new Error("Got message on unexpected message manager");
     }
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/PerformanceCounters.jsm
@@ -0,0 +1,126 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/**
+ * This module contains a global counter to store API call in the current process.
+ */
+
+/* exported Counters */
+var EXPORTED_SYMBOLS = ["PerformanceCounters"];
+
+ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+const {
+  DefaultMap,
+} = ExtensionUtils;
+
+XPCOMUtils.defineLazyPreferenceGetter(this, "gTimingEnabled",
+                                      "extensions.webextensions.enablePerformanceCounters",
+                                      false);
+XPCOMUtils.defineLazyPreferenceGetter(this, "gTimingMaxAge",
+                                      "extensions.webextensions.performanceCountersMaxAge",
+                                      0);
+
+class CounterMap extends DefaultMap {
+  defaultConstructor() {
+    return new DefaultMap(() => ({duration: 0, calls: 0}));
+  }
+}
+
+class Counters {
+  constructor() {
+    this.data = new CounterMap();
+  }
+
+  /**
+   * Returns true if performance counters are enabled.
+   *
+   * Indirection used so gTimingEnabled is not exposed direcly
+   * in PerformanceCounters -- which would prevent tests to dynamically
+   * change the preference value once PerformanceCounters.jsm is loaded.
+   *
+   * @returns {boolean}
+   */
+  get enabled() {
+    return gTimingEnabled;
+  }
+
+  /**
+   * Returns the counters max age
+   *
+   * Indirection used so gTimingMaxAge is not exposed direcly
+   * in PerformanceCounters -- which would prevent tests to dynamically
+   * change the preference value once PerformanceCounters.jsm is loaded.
+   *
+   * @returns {number}
+   */
+  get maxAge() {
+    return gTimingMaxAge;
+  }
+
+  /**
+   * Stores an execution time.
+   *
+   * @param {string} webExtensionId The web extension id.
+   * @param {string} apiPath The API path.
+   * @param {integer} duration How long the call took.
+   */
+  storeExecutionTime(webExtensionId, apiPath, duration) {
+    let apiCounter = this.data.get(webExtensionId).get(apiPath);
+    apiCounter.duration += duration;
+    apiCounter.calls += 1;
+  }
+
+  /**
+   * Merges another CounterMap into this.data
+   *
+   * Can be used by the main process to merge data received
+   * from the children.
+   *
+   * @param {CounterMap} data The map to merge.
+   */
+  merge(data) {
+    for (let [webextId, counters] of data) {
+      for (let [api, counter] of counters) {
+        let current = this.data.get(webextId).get(api);
+        current.calls += counter.calls;
+        current.duration += counter.duration;
+      }
+    }
+  }
+
+  /**
+   * Returns the performance counters and purges them.
+   *
+   * @returns {CounterMap}
+   */
+  flush() {
+    let result = new CounterMap();
+    for (let [webextId, counters] of this.data) {
+      for (let [api, counter] of counters) {
+        let current = result.get(webextId).get(api);
+        current.calls += counter.calls;
+        current.duration += counter.duration;
+      }
+    }
+    this.data = new CounterMap();
+    return result;
+  }
+
+  /**
+   * Returns the performance counters.
+   *
+   * @returns {CounterMap}
+   */
+  getData() {
+    return this.data;
+  }
+}
+
+
+var PerformanceCounters = new Counters();
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -24,16 +24,17 @@ EXTRA_JS_MODULES += [
     'ExtensionStorageSync.jsm',
     'ExtensionUtils.jsm',
     'FindContent.jsm',
     'LegacyExtensionsUtils.jsm',
     'MessageChannel.jsm',
     'MessageManagerProxy.jsm',
     'NativeManifests.jsm',
     'NativeMessaging.jsm',
+    'PerformanceCounters.jsm',
     'ProxyScriptContext.jsm',
     'Schemas.jsm',
 ]
 
 EXTRA_COMPONENTS += [
     'extension-process-script.js',
     'extensions-toolkit.manifest',
 ]
--- a/toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.js
@@ -1,43 +1,103 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 ChromeUtils.import("resource://gre/modules/ExtensionParent.jsm");
+ChromeUtils.import("resource://gre/modules/PerformanceCounters.jsm");
+ChromeUtils.import("resource://gre/modules/Promise.jsm");
+
 const ENABLE_COUNTER_PREF = "extensions.webextensions.enablePerformanceCounters";
+const TIMING_MAX_AGE = "extensions.webextensions.performanceCountersMaxAge";
 
 let {
   ParentAPIManager,
 } = ExtensionParent;
 
+/*
+ * Monkey-patching PerformanceCounters so we know when data gets back from
+ * the child.
+ */
+let CounterObserver = {
+  init(apiName, expectedCount) {
+    this.old_merge = PerformanceCounters.merge;
+    PerformanceCounters.merge = this.merge;
+    PerformanceCounters.received = new Promise(resolve => {
+      PerformanceCounters.resolve = resolve;
+    });
+    PerformanceCounters.currentCount = 0;
+    PerformanceCounters.expectedCount = expectedCount;
+    PerformanceCounters.apiName = apiName;
+  },
+
+  uninit() {
+    PerformanceCounters.merge = this.old_merge;
+  },
+
+  async waitForCounters() {
+    return PerformanceCounters.received;
+  },
+
+  merge(data) {
+    let original = PerformanceCounters.getData();
+    for (let [webextId, counters] of data) {
+      for (let [api, counter] of counters) {
+        if (api == this.apiName) {
+          this.currentCount += counter.calls;
+        }
+        let current = original.get(webextId).get(api);
+        current.calls += counter.calls;
+        current.duration += counter.duration;
+      }
+    }
+    if (this.currentCount >= this.expectedCount) {
+      this.resolve();
+    }
+  },
+};
 
 async function test_counter() {
   async function background() {
-    // creating a bookmark
+    // creating a bookmark is done in the parent
     let folder = await browser.bookmarks.create({title: "Folder"});
     await browser.bookmarks.create({title: "Bookmark", url: "http://example.com",
                                     parentId: folder.id});
+
+    // getURL() is done in the child, let do three
+    browser.extension.getURL("beasts/frog.html");
+    browser.extension.getURL("beasts/frog2.html");
+    browser.extension.getURL("beasts/frog3.html");
     browser.test.sendMessage("done");
   }
 
   let extensionData = {
     background,
     manifest: {
       permissions: ["bookmarks"],
     },
   };
 
   let extension = ExtensionTestUtils.loadExtension(extensionData);
+  CounterObserver.init("getURL", 3);
   await extension.startup();
   await extension.awaitMessage("done");
+  await CounterObserver.waitForCounters();
+  CounterObserver.uninit();
+
+  let counters = await ParentAPIManager.retrievePerformanceCounters();
   await extension.unload();
 
   // check that the bookmarks.create API was tracked
-  let counters = ParentAPIManager.performanceCounters;
   let counter = counters.get(extension.id).get("bookmarks.create");
   ok(counter.calls > 0);
   ok(counter.duration > 0);
+
+  // check that the getURL API was tracked
+  counter = counters.get(extension.id).get("getURL");
+  ok(counter.calls > 0);
+  ok(counter.duration > 0);
 }
 
 add_task(function test_performance_counter() {
-  return runWithPrefs([[ENABLE_COUNTER_PREF, true]], test_counter);
+  return runWithPrefs([[ENABLE_COUNTER_PREF, true],
+                       [TIMING_MAX_AGE, 1]], test_counter);
 });