Bug 1420485 - Reduce memory usage related to the tabs.insertCSS cssCode urls. r=mixedpuppy, a=RyanVM
authorLuca Greco <lgreco@mozilla.com>
Fri, 24 Nov 2017 19:17:33 +0100
changeset 454718 16970266565679053bf6a9c63ad6084c14422d0b
parent 454717 05fd88206d24afef45187cf35c6f8b04d496fc9b
child 454719 f0c62c006c4206cb8c5ea71ec164d962b7c75a22
push id1648
push usermtabara@mozilla.com
push dateThu, 01 Mar 2018 12:45:47 +0000
treeherdermozilla-release@cbb9688c2eeb [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmixedpuppy, RyanVM
bugs1420485
milestone59.0
Bug 1420485 - Reduce memory usage related to the tabs.insertCSS cssCode urls. r=mixedpuppy, a=RyanVM This patch introduces a cache of blob urls which are related to the cssCode injected by the extensions using tabs.insertCSS, which aims to reduce the amount of memory used by the extensions to store the cssCode injected into the tabs and their subframes in the content child process. The generated Blob URL are associated to the extension principal, so that it is easier to identify in the about:memory reports which is extension that is using the cached data. MozReview-Commit-ID: ERhR5nmVMqY
toolkit/components/extensions/ExtensionContent.jsm
toolkit/components/extensions/ExtensionUtils.jsm
toolkit/components/extensions/extension-process-script.js
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -44,16 +44,17 @@ const {
   DefaultMap,
   DefaultWeakMap,
   defineLazyGetter,
   getInnerWindowID,
   getWinUtils,
   promiseDocumentLoaded,
   promiseDocumentReady,
   runSafeSyncWithoutClone,
+  stringToCryptoHash,
 } = ExtensionUtils;
 
 const {
   BaseContext,
   CanOfAPIs,
   SchemaAPIManager,
 } = ExtensionCommon;
 
@@ -87,16 +88,17 @@ var apiManager = new class extends Schem
     }
   }
 }();
 
 const SCRIPT_EXPIRY_TIMEOUT_MS = 5 * 60 * 1000;
 const SCRIPT_CLEAR_TIMEOUT_MS = 5 * 1000;
 
 const CSS_EXPIRY_TIMEOUT_MS = 30 * 60 * 1000;
+const CSSCODE_EXPIRY_TIMEOUT_MS = 10 * 60 * 1000;
 
 const scriptCaches = new WeakSet();
 const sheetCacheDocuments = new DefaultWeakMap(() => new WeakSet());
 
 class CacheMap extends DefaultMap {
   constructor(timeout, getter) {
     super(getter);
 
@@ -147,49 +149,94 @@ class ScriptCache extends CacheMap {
     let promise = ChromeUtils.compileScript(url, this.options);
     promise.then(script => {
       promise.script = script;
     });
     return promise;
   }
 }
 
-class CSSCache extends CacheMap {
-  constructor(sheetType) {
-    super(CSS_EXPIRY_TIMEOUT_MS, url => {
-      let uri = Services.io.newURI(url);
-      return styleSheetService.preloadSheetAsync(uri, sheetType).then(sheet => {
-        return {url, sheet};
-      });
-    });
+/**
+ * Shared base class for the two specialized CSS caches:
+ * CSSCache (for the "url"-based stylesheets) and CSSCodeCache
+ * (for the stylesheet defined by plain CSS content as a string).
+ */
+class BaseCSSCache extends CacheMap {
+  addDocument(key, document) {
+    sheetCacheDocuments.get(this.get(key)).add(document);
   }
 
-  addDocument(url, document) {
-    sheetCacheDocuments.get(this.get(url)).add(document);
+  deleteDocument(key, document) {
+    sheetCacheDocuments.get(this.get(key)).delete(document);
   }
 
-  deleteDocument(url, document) {
-    sheetCacheDocuments.get(this.get(url)).delete(document);
-  }
-
-  delete(url) {
-    if (this.has(url)) {
-      let promise = this.get(url);
+  delete(key) {
+    if (this.has(key)) {
+      let promise = this.get(key);
 
       // Never remove a sheet from the cache if it's still being used by a
       // document. Rule processors can be shared between documents with the
       // same preloaded sheet, so we only lose by removing them while they're
       // still in use.
       let docs = ChromeUtils.nondeterministicGetWeakSetKeys(sheetCacheDocuments.get(promise));
       if (docs.length) {
         return;
       }
     }
 
-    super.delete(url);
+    super.delete(key);
+  }
+}
+
+/**
+ * Cache of the preloaded stylesheet defined by url.
+ */
+class CSSCache extends BaseCSSCache {
+  constructor(sheetType) {
+    super(CSS_EXPIRY_TIMEOUT_MS, url => {
+      let uri = Services.io.newURI(url);
+      return styleSheetService.preloadSheetAsync(uri, sheetType).then(sheet => {
+        return {url, sheet};
+      });
+    });
+  }
+}
+
+/**
+ * Cache of the preloaded stylesheet defined by plain CSS content as a string,
+ * the key of the cached stylesheet is the hash of its "CSSCode" string.
+ */
+class CSSCodeCache extends BaseCSSCache {
+  constructor(sheetType, extension) {
+    super(CSSCODE_EXPIRY_TIMEOUT_MS, (hash) => {
+      if (!this.has(hash)) {
+        // Do not allow the getter to be used to lazily create the cached stylesheet,
+        // the cached CSSCode stylesheet has to be explicitly set.
+        throw new Error("Unexistent cached cssCode stylesheet: " + Error().stack);
+      }
+
+      return super.get(hash);
+    });
+
+    // Store the preferred sheetType (used to preload the expected stylesheet type in
+    // the addCSSCode method).
+    this.sheetType = sheetType;
+  }
+
+  addCSSCode(hash, cssCode) {
+    if (this.has(hash)) {
+      // This cssCode have been already cached, no need to create it again.
+      return;
+    }
+    const uri = Services.io.newURI("data:text/css;charset=utf-8," + encodeURIComponent(cssCode));
+    const value = styleSheetService.preloadSheetAsync(uri, this.sheetType).then(sheet => {
+      return {sheet, uri};
+    });
+
+    super.set(hash, value);
   }
 }
 
 defineLazyGetter(BrowserExtensionContent.prototype, "staticScripts", () => {
   return new ScriptCache({hasReturnValue: false});
 });
 
 defineLazyGetter(BrowserExtensionContent.prototype, "dynamicScripts", () => {
@@ -199,67 +246,112 @@ defineLazyGetter(BrowserExtensionContent
 defineLazyGetter(BrowserExtensionContent.prototype, "userCSS", () => {
   return new CSSCache(Ci.nsIStyleSheetService.USER_SHEET);
 });
 
 defineLazyGetter(BrowserExtensionContent.prototype, "authorCSS", () => {
   return new CSSCache(Ci.nsIStyleSheetService.AUTHOR_SHEET);
 });
 
+// These two caches are similar to the above but specialized to cache the cssCode
+// using an hash computed from the cssCode string as the key (instead of the generated data
+// URI which can be pretty long for bigger injected cssCode).
+defineLazyGetter(BrowserExtensionContent.prototype, "userCSSCode", function() {
+  return new CSSCodeCache(Ci.nsIStyleSheetService.USER_SHEET, this);
+});
+
+defineLazyGetter(BrowserExtensionContent.prototype, "authorCSSCode", function() {
+  return new CSSCodeCache(Ci.nsIStyleSheetService.AUTHOR_SHEET, this);
+});
+
 // Represents a content script.
 class Script {
   constructor(extension, matcher) {
     this.extension = extension;
     this.matcher = matcher;
 
     this.runAt = this.matcher.runAt;
     this.js = this.matcher.jsPaths;
-    this.css = this.matcher.cssPaths;
+    this.css = this.matcher.cssPaths.slice();
+    this.cssCodeHash = null;
+
     this.removeCSS = this.matcher.removeCSS;
     this.cssOrigin = this.matcher.cssOrigin;
 
-    this.cssCache = extension[this.cssOrigin === "user" ? "userCSS"
-                                                        : "authorCSS"];
-    this.scriptCache = extension[matcher.wantReturnValue ? "dynamicScripts"
-                                                         : "staticScripts"];
+    this.cssCache = extension[
+      this.cssOrigin === "user" ? "userCSS" : "authorCSS"
+    ];
+    this.cssCodeCache = extension[
+      this.cssOrigin === "user" ? "userCSSCode" : "authorCSSCode"
+    ];
+    this.scriptCache = extension[
+      matcher.wantReturnValue ? "dynamicScripts" : "staticScripts"
+    ];
 
     if (matcher.wantReturnValue) {
       this.compileScripts();
       this.loadCSS();
     }
+  }
 
-    this.requiresCleanup = !this.removeCss && (this.css.length > 0 || matcher.cssCode);
+  get requiresCleanup() {
+    return !this.removeCss && (this.css.length > 0 || this.cssCodeHash);
+  }
+
+  async addCSSCode(cssCode) {
+    if (!cssCode) {
+      return;
+    }
+
+    // Store the hash of the cssCode.
+    this.cssCodeHash = await stringToCryptoHash(cssCode);
+
+    // Cache and preload the cssCode stylesheet.
+    this.cssCodeCache.addCSSCode(this.cssCodeHash, cssCode);
   }
 
   compileScripts() {
     return this.js.map(url => this.scriptCache.get(url));
   }
 
   loadCSS() {
-    return this.cssURLs.map(url => this.cssCache.get(url));
+    return this.css.map(url => this.cssCache.get(url));
   }
 
   preload() {
     this.loadCSS();
     this.compileScripts();
   }
 
-  cleanup(window) {
-    if (!this.removeCss && this.cssURLs.length) {
-      let winUtils = getWinUtils(window);
+  cleanup(window, forceCacheClear = false) {
+    if (this.requiresCleanup) {
+      if (window) {
+        let winUtils = getWinUtils(window);
+
+        let type = this.cssOrigin === "user" ? winUtils.USER_SHEET : winUtils.AUTHOR_SHEET;
 
-      let type = this.cssOrigin === "user" ? winUtils.USER_SHEET : winUtils.AUTHOR_SHEET;
-      for (let url of this.cssURLs) {
-        this.cssCache.deleteDocument(url, window.document);
-        runSafeSyncWithoutClone(winUtils.removeSheetUsingURIString, url, type);
+        for (let url of this.css) {
+          this.cssCache.deleteDocument(url, window.document);
+          runSafeSyncWithoutClone(winUtils.removeSheetUsingURIString, url, type);
+        }
+
+        const {cssCodeHash} = this;
+
+        if (cssCodeHash && this.cssCodeCache.has(cssCodeHash)) {
+          this.cssCodeCache.get(cssCodeHash).then(({uri}) => {
+            runSafeSyncWithoutClone(winUtils.removeSheet, uri, type);
+          });
+          this.cssCodeCache.deleteDocument(cssCodeHash, window.document);
+        }
       }
 
       // Clear any sheets that were kept alive past their timeout as
       // a result of living in this document.
-      this.cssCache.clear(CSS_EXPIRY_TIMEOUT_MS);
+      this.cssCodeCache.clear(forceCacheClear ? 0 : CSSCODE_EXPIRY_TIMEOUT_MS);
+      this.cssCache.clear(forceCacheClear ? 0 : CSS_EXPIRY_TIMEOUT_MS);
     }
   }
 
   matchesWindow(window) {
     return this.matcher.matchesWindow(window);
   }
 
   async injectInto(window) {
@@ -296,42 +388,60 @@ class Script {
    *        execution is complete.
    */
   async inject(context) {
     DocumentManager.lazyInit();
     if (this.requiresCleanup) {
       context.addScript(this);
     }
 
+    const {cssCodeHash} = this;
+
     let cssPromise;
-    if (this.cssURLs.length) {
+    if (this.css.length || cssCodeHash) {
       let window = context.contentWindow;
       let winUtils = getWinUtils(window);
 
       let type = this.cssOrigin === "user" ? winUtils.USER_SHEET : winUtils.AUTHOR_SHEET;
 
       if (this.removeCSS) {
-        for (let url of this.cssURLs) {
+        for (let url of this.css) {
           this.cssCache.deleteDocument(url, window.document);
 
           runSafeSyncWithoutClone(winUtils.removeSheetUsingURIString, url, type);
         }
+
+        if (cssCodeHash && this.cssCodeCache.has(cssCodeHash)) {
+          const {uri} = await this.cssCodeCache.get(cssCodeHash);
+          this.cssCodeCache.deleteDocument(cssCodeHash, window.document);
+
+          runSafeSyncWithoutClone(winUtils.removeSheet, uri, type);
+        }
       } else {
         cssPromise = Promise.all(this.loadCSS()).then(sheets => {
           let window = context.contentWindow;
           if (!window) {
             return;
           }
 
           for (let {url, sheet} of sheets) {
             this.cssCache.addDocument(url, window.document);
 
             runSafeSyncWithoutClone(winUtils.addSheet, sheet, type);
           }
         });
+
+        if (cssCodeHash) {
+          cssPromise = cssPromise.then(async () => {
+            const {sheet} = await this.cssCodeCache.get(cssCodeHash);
+            this.cssCodeCache.addDocument(cssCodeHash, window.document);
+
+            runSafeSyncWithoutClone(winUtils.addSheet, sheet, type);
+          });
+        }
       }
     }
 
     let scriptPromises = this.compileScripts();
 
     let scripts = scriptPromises.map(promise => promise.script);
     // If not all scripts are already available in the cache, block
     // parsing and wait all promises to resolve.
@@ -366,27 +476,16 @@ class Script {
       TelemetryStopwatch.finish(CONTENT_SCRIPT_INJECTION_HISTOGRAM, context);
     }
 
     await cssPromise;
     return result;
   }
 }
 
-defineLazyGetter(Script.prototype, "cssURLs", function() {
-  // We can handle CSS urls (css) and CSS code (cssCode).
-  let urls = this.css.slice();
-
-  if (this.matcher.cssCode) {
-    urls.push("data:text/css;charset=utf-8," + encodeURIComponent(this.matcher.cssCode));
-  }
-
-  return urls;
-});
-
 /**
  * An execution context for semi-privileged extension content scripts.
  *
  * This is the child side of the ContentScriptContextParent class
  * defined in ExtensionParent.jsm.
  */
 class ContentScriptContextChild extends BaseContext {
   constructor(extension, contentWindow) {
@@ -506,24 +605,31 @@ class ContentScriptContextChild extends 
   }
 
   addScript(script) {
     if (script.requiresCleanup) {
       this.scripts.push(script);
     }
   }
 
+  cleanupScripts(forceCacheClear = false) {
+    // Cleanup the scripts (even if the contentWindow have been destroyed) and their
+    // related CSS and Script caches.
+    for (let script of this.scripts) {
+      script.cleanup(this.contentWindow, forceCacheClear);
+    }
+  }
+
   close() {
     super.unload();
 
+    // Cleanup the scripts even if the contentWindow have been destroyed.
+    this.cleanupScripts();
+
     if (this.contentWindow) {
-      for (let script of this.scripts) {
-        script.cleanup(this.contentWindow);
-      }
-
       // Overwrite the content script APIs with an empty object if the APIs objects are still
       // defined in the content window (See Bug 1214658).
       if (this.isExtensionPage) {
         Cu.createObjectIn(this.contentWindow, {defineAs: "browser"});
         Cu.createObjectIn(this.contentWindow, {defineAs: "chrome"});
       }
     }
     Cu.nukeSandbox(this.sandbox);
@@ -607,16 +713,20 @@ DocumentManager = {
   observe(subject, topic, data) {
     this.observers[topic].call(this, subject, topic, data);
   },
 
   shutdownExtension(extension) {
     for (let extensions of this.contexts.values()) {
       let context = extensions.get(extension);
       if (context) {
+        // Passing true to context.cleanupScripts causes the caches for this context
+        // to be cleared.
+        context.cleanupScripts(true);
+
         context.close();
         extensions.delete(extension);
       }
     }
   },
 
   getContexts(window) {
     let winId = getInnerWindowID(window);
--- a/toolkit/components/extensions/ExtensionUtils.jsm
+++ b/toolkit/components/extensions/ExtensionUtils.jsm
@@ -10,25 +10,34 @@ this.EXPORTED_SYMBOLS = ["ExtensionUtils
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "ConsoleAPI",
                                   "resource://gre/modules/Console.jsm");
 
+Cu.importGlobalProperties(["crypto", "TextDecoder", "TextEncoder"]);
+
 function getConsole() {
   return new ConsoleAPI({
     maxLogLevelPref: "extensions.webextensions.log.level",
     prefix: "WebExtensions",
   });
 }
 
 XPCOMUtils.defineLazyGetter(this, "console", getConsole);
 
+XPCOMUtils.defineLazyGetter(this, "utf8Encoder", () => {
+  return new TextEncoder("utf-8");
+});
+XPCOMUtils.defineLazyGetter(this, "utf8Decoder", () => {
+  return new TextDecoder("utf-8");
+});
+
 // It would be nicer to go through `Services.appinfo`, but some tests need to be
 // able to replace that field with a custom implementation before it is first
 // called.
 // eslint-disable-next-line mozilla/use-services
 const appinfo = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime);
 
 let nextId = 0;
 const uniqueProcessID = appinfo.uniqueProcessID;
@@ -629,16 +638,31 @@ function checkLoadURL(url, principal, op
                                   Services.io.newURI(url),
                                   flags);
   } catch (e) {
     return false;
   }
   return true;
 }
 
+/**
+ * Return the cryptographic hash given a string of text (using MD5 by default).
+ *
+ * @param {string} text
+ *   The string of text to hash.
+ * @param {string} [algo]
+ *   An optional algorithm to be re-used to generate the hash ("SHA-1" by default).
+ * @returns {string} text
+ *   The hashed string.
+ */
+async function stringToCryptoHash(text, algo = "SHA-1") {
+  const buffer = await crypto.subtle.digest(algo, utf8Encoder.encode(text));
+  return utf8Decoder.decode(buffer);
+}
+
 this.ExtensionUtils = {
   checkLoadURL,
   defineLazyGetter,
   flushJarCache,
   getConsole,
   getInnerWindowID,
   getMessageManager,
   getUniqueId,
@@ -646,16 +670,17 @@ this.ExtensionUtils = {
   getWinUtils,
   instanceOf,
   normalizeTime,
   promiseDocumentLoaded,
   promiseDocumentReady,
   promiseEvent,
   promiseObserved,
   runSafeSyncWithoutClone,
+  stringToCryptoHash,
   withHandlingUserInput,
   DefaultMap,
   DefaultWeakMap,
   EventEmitter,
   ExtensionError,
   LimitedSet,
   MessageManagerProxy,
 };
--- a/toolkit/components/extensions/extension-process-script.js
+++ b/toolkit/components/extensions/extension-process-script.js
@@ -109,17 +109,17 @@ class ExtensionGlobal {
 
   getFrameData(force = false) {
     if (!this.frameData && force) {
       this.frameData = this.global.sendSyncMessage("Extension:GetTabAndWindowId")[0];
     }
     return this.frameData;
   }
 
-  receiveMessage({target, messageName, recipient, data, name}) {
+  async receiveMessage({target, messageName, recipient, data, name}) {
     switch (name) {
       case "Extension:SetFrameData":
         if (this.frameData) {
           Object.assign(this.frameData, data);
         } else {
           this.frameData = data;
         }
         if (data.viewType && WebExtensionPolicy.isExtensionProcess) {
@@ -137,22 +137,25 @@ class ExtensionGlobal {
         let policy = WebExtensionPolicy.getByID(recipient.extensionId);
 
         let matcher = new WebExtensionContentScript(policy, parseScriptOptions(data.options));
 
         Object.assign(matcher, {
           wantReturnValue: data.options.wantReturnValue,
           removeCSS: data.options.remove_css,
           cssOrigin: data.options.css_origin,
-          cssCode: data.options.cssCode,
           jsCode: data.options.jsCode,
         });
 
         let script = contentScripts.get(matcher);
 
+        // Add the cssCode to the script, so that it can be converted into a cached URL.
+        await script.addCSSCode(data.options.cssCode);
+        delete data.options.cssCode;
+
         return ExtensionContent.handleExtensionExecute(this.global, target, data.options, script);
       case "WebNavigation:GetFrame":
         return ExtensionContent.handleWebNavigationGetFrame(this.global, data.options);
       case "WebNavigation:GetAllFrames":
         return ExtensionContent.handleWebNavigationGetAllFrames(this.global);
     }
   }
 }