Bug 451670: Discard tab data when low on memory, r=mfinkle, r=stuart
authorBenjamin Stover <bstover@mozilla.com>
Sat, 05 Sep 2009 01:14:59 -0400
changeset 833 d1841c66f06f6926947f8544148870011a10b045
parent 832 d66f87a591c6c8e6202e3532ed52d74e02b9c936
child 834 48ea8038933facef2135ae82f19b8957ded77d41
push id719
push usermfinkle@mozilla.com
push dateSat, 05 Sep 2009 05:15:54 +0000
reviewersmfinkle, stuart
bugs451670
Bug 451670: Discard tab data when low on memory, r=mfinkle, r=stuart
.hgignore
chrome/content/BrowserView.js
chrome/content/Util.js
chrome/content/browser.js
--- a/.hgignore
+++ b/.hgignore
@@ -1,7 +1,8 @@
 # .hgignore - List of filenames hg should ignore
 
 # Filenames that should be ignored wherever they appear
 ~$
 \.pyc$
+\.swp$
 (^|/)TAGS$
 \.DS_Store$
--- a/chrome/content/BrowserView.js
+++ b/chrome/content/BrowserView.js
@@ -155,26 +155,18 @@ BrowserView.Util = {
     let rounded = Math.round(bounded * kBrowserViewZoomLevelPrecision) / kBrowserViewZoomLevelPrecision;
     return (rounded) ? rounded : 1.0;
   },
 
   pageZoomLevel: function pageZoomLevel(visibleRect, browserW, browserH) {
     return BrowserView.Util.clampZoomLevel(visibleRect.width / browserW);
   },
 
-  createBrowserViewportState: function createBrowserViewportState(browser, visibleRect) {
-    let [browserW, browserH] = BrowserView.Util.getBrowserDimensions(browser);
-
-    let zoomLevel = BrowserView.Util.pageZoomLevel(visibleRect, browserW, browserH);
-    let viewportRect = (new wsRect(0, 0, browserW, browserH)).scale(zoomLevel, zoomLevel);
-
-    return new BrowserView.BrowserViewportState(viewportRect,
-                                                visibleRect.x,
-                                                visibleRect.y,
-                                                zoomLevel);
+  createBrowserViewportState: function createBrowserViewportState() {
+    return new BrowserView.BrowserViewportState(new wsRect(0, 0, 1, 1), 0, 0, 1);
   },
 
   getViewportStateFromBrowser: function getViewportStateFromBrowser(browser) {
     return browser.__BrowserView__vps;
   },
 
   getBrowserDimensions: function getBrowserDimensions(browser) {
     let cdoc = browser.contentDocument;
@@ -277,16 +269,17 @@ BrowserView.prototype = {
 
     if (newZL != bvs.zoomLevel) {
       let browserW = this.viewportToBrowser(bvs.viewportRect.right);
       let browserH = this.viewportToBrowser(bvs.viewportRect.bottom);
       bvs.zoomLevel = newZL; // side-effect: now scale factor in transformations is newZL
       this.setViewportDimensions(this.browserToViewport(browserW),
                                  this.browserToViewport(browserH),
                                  true);
+      this.zoomChanged = true;
     }
   },
 
   getZoomLevel: function getZoomLevel() {
     let bvs = this._browserViewportState;
     if (!bvs)
       return undefined;
 
@@ -678,16 +671,17 @@ BrowserView.BrowserViewportState = funct
 
 BrowserView.BrowserViewportState.prototype = {
 
   init: function init(viewportRect, visibleX, visibleY, zoomLevel) {
     this.viewportRect = viewportRect;
     this.visibleX     = visibleX;
     this.visibleY     = visibleY;
     this.zoomLevel    = zoomLevel;
+    this.zoomChanged  = false;
   },
 
   clone: function clone() {
     return new BrowserView.BrowserViewportState(this.viewportRect,
                                                 this.visibleX,
                                                 this.visibleY,
 						                                    this.zoomLevel);
   },
--- a/chrome/content/Util.js
+++ b/chrome/content/Util.js
@@ -54,19 +54,31 @@ let Util = {
 
   bindAll: function bindAll(instance) {
     let bind = Util.bind;
     for (let key in instance)
       if (instance[key] instanceof Function)
         instance[key] = bind(instance[key], instance);
   },
 
+  /** printf-like dump function */
+  dumpf: function dumpf(str) {
+    var args = arguments;
+    var i = 1;
+    dump(str.replace(/%s/g, function() {
+      if (i >= args.length) {
+        throw "dumps received too many placeholders and not enough arguments";
+      }
+      return args[i++].toString();
+    }));
+  },
+
   /** Like dump, but each arg is handled and there's an automatic newline */
   dumpLn: function dumpLn() {
-    for (var i = 0; i < arguments.length; i++) { dump(arguments[i] + ' '); }
+    for (var i = 0; i < arguments.length; i++) { dump(arguments[i] + " "); }
     dump("\n");
   },
 
   /** Executes aFunc after other events have been processed. */
   executeSoon: function executeSoon(aFunc) {
     let tm = Cc["@mozilla.org/thread-manager;1"].getService(Ci.nsIThreadManager);
     tm.mainThread.dispatch({
       run: function() {
--- a/chrome/content/browser.js
+++ b/chrome/content/browser.js
@@ -172,17 +172,17 @@ function onDebugKeyPress(ev) {
 
   // use capitals so we require SHIFT here too
 
   const a = 65;   // debug all critical tiles
   const b = 66;   // dump an ASCII graphic of the tile map
   const c = 67;   // set tilecache capacity
   const d = 68;  // debug dump
   const e = 69;
-  const f = 70;
+  const f = 70;  // free memory by clearing a tab.
   const g = 71;
   const h = 72;
   const i = 73;  // toggle info click mode
   const j = 74;
   const k = 75;
   const l = 76;  // restart lazy crawl
   const m = 77;  // fix mouseout
   const n = 78;
@@ -245,16 +245,24 @@ function onDebugKeyPress(ev) {
 
     dump(endl + endl);
 
     window.tileMapMode = false;
     return;
   }
 
   switch (ev.charCode) {
+  case f:
+    var result = Browser.sacrificeTab();
+    if (result)
+      dump("Freed a tab\n");
+    else
+      dump("There are no tabs left to free\n");
+    break;
+
   case r:
     bv.onAfterVisibleMove();
     //bv.setVisibleRect(Browser.getVisibleRect());
 
   case d:
     debug();
 
     break;
@@ -320,17 +328,16 @@ function onDebugKeyPress(ev) {
 }
 window.infoMode = false;
 window.tileMapMode = false;
 
 var ih = null;
 
 var Browser = {
   _tabs : [],
-  _browsers : [],
   _selectedTab : null,
   windowUtils: window.QueryInterface(Ci.nsIInterfaceRequestor)
                      .getInterface(Ci.nsIDOMWindowUtils),
   contentScrollbox: null,
   contentScrollboxScroller: null,
   controlsScrollbox: null,
   controlsScrollboxScroller: null,
 
@@ -454,18 +461,18 @@ var Browser = {
 
     var os = Cc["@mozilla.org/observer-service;1"].getService(Ci.nsIObserverService);
     os.addObserver(gXPInstallObserver, "xpinstall-install-blocked", false);
     os.addObserver(gSessionHistoryObserver, "browser:purge-session-history", false);
 #ifdef WINCE
     os.addObserver(SoftKeyboardObserver, "softkb-change", false);
 #endif
 
-    // XXX hook up memory-pressure notification to clear out tab browsers
-    //os.addObserver(function(subject, topic, data) self.destroyEarliestBrowser(), "memory-pressure", false);
+    // clear out tabs the user hasn't touched lately on memory crunch
+    os.addObserver(MemoryObserver, "memory-pressure", false);
 
     window.QueryInterface(Ci.nsIDOMChromeWindow).browserDOMWindow = new nsBrowserAccess();
 
     let browsers = document.getElementById("browsers");
     browsers.addEventListener("command", this._handleContentCommand, false);
     browsers.addEventListener("DOMUpdatePageReport", gPopupBlockerObserver.onUpdatePageReport, false);
 
     /* Initialize Spatial Navigation */
@@ -543,16 +550,17 @@ var Browser = {
   shutdown: function() {
     this._browserView.setBrowser(null, null, false);
 
     BrowserUI.uninit();
 
     var os = Cc["@mozilla.org/observer-service;1"].getService(Ci.nsIObserverService);
     os.removeObserver(gXPInstallObserver, "xpinstall-install-blocked");
     os.removeObserver(gSessionHistoryObserver, "browser:purge-session-history");
+    os.removeObserver(MemoryObserver, "memory-pressure");
 #ifdef WINCE
     os.removeObserver(SoftKeyboardObserver, "softkb-change");
 #endif
 
     window.controllers.removeController(this);
     window.controllers.removeController(BrowserUI);
   },
 
@@ -560,17 +568,17 @@ var Browser = {
   {
     var phs = Cc["@mozilla.org/plugin/host;1"].getService(Ci.nsIPluginHost);
     var plugins = phs.getPluginTags({ });
     for (var i = 0; i < plugins.length; ++i)
       plugins[i].disabled = !enabled;
   },
 
   get browsers() {
-    return this._browsers;
+    return this._tabs.map(function(tab) { return tab.browser; });
   },
 
   scrollContentToTop: function scrollContentToTop() {
     this.contentScrollboxScroller.scrollTo(0, 0);
     this._browserView.onAfterVisibleMove();
   },
 
   hideSidebars: function scrollSidebarsOffscreen() {
@@ -609,17 +617,16 @@ var Browser = {
         return this._tabs[t];
     }
     return null;
   },
 
   addTab: function(uri, bringFront) {
     let newTab = new Tab();
     this._tabs.push(newTab);
-    this._browsers.push(newTab.browser);
 
     if (bringFront)
       this.selectedTab = newTab;
 
     newTab.load(uri);
 
     let event = document.createEvent("Events");
     event.initEvent("TabOpen", true, false);
@@ -647,17 +654,16 @@ var Browser = {
     let event = document.createEvent("Events");
     event.initEvent("TabClose", true, false);
     tab.chromeTab.dispatchEvent(event);
 
     this.selectedTab = nextTab;
 
     tab.destroy();
     this._tabs.splice(tabIndex, 1);
-    this._browsers.splice(tabIndex, 1);
 
     // redraw the tabs
     for (let t = tabIndex; t < this._tabs.length; t++)
       this._tabs[t].updateThumbnail();
   },
 
   get selectedTab() {
     return this._selectedTab;
@@ -667,35 +673,30 @@ var Browser = {
     let bv = this._browserView;
 
     if (tab instanceof XULElement)
       tab = this.getTabFromChrome(tab);
 
     if (!tab || this._selectedTab == tab)
       return;
 
+    if (this._selectedTab) {
+      this._selectedTab.scrollOffset = this.getScrollboxPosition(this.contentScrollboxScroller);
+    }
+
     let firstTab = this._selectedTab == null;
     this._selectedTab = tab;
 
+    tab.ensureBrowserExists();
+
     bv.beginBatchOperation();
 
     bv.setBrowser(tab.browser, tab.browserViewportState, false);
     bv.forceContainerResize();
 
-    // XXX these should probably be computed less hackily so they don't
-    //   potentially break if we change something in browser.xul
-    let offY = Math.round(document.getElementById("toolbar-container").getBoundingClientRect().height);
-    let restoreX = Math.max(0, tab.browserViewportState.visibleX);
-    let restoreY = Math.max(0, tab.browserViewportState.visibleY) + offY;
-
-    //dump('Switch tab scrolls to: ' + restoreX
-    //                        + ', ' + restoreY + '\n');
-
-    Browser.contentScrollboxScroller.scrollTo(restoreX, restoreY);
-
     document.getElementById("tabs").selectedItem = tab.chromeTab;
 
     if (!firstTab) {
       let webProgress = this.selectedBrowser.webProgress;
       let securityUI = this.selectedBrowser.securityUI;
 
       try {
         tab._listener.onLocationChange(webProgress, null, tab.browser.currentURI);
@@ -706,16 +707,24 @@ var Browser = {
         Components.utils.reportError(e);
       }
 
       let event = document.createEvent("Events");
       event.initEvent("TabSelect", true, false);
       tab.chromeTab.dispatchEvent(event);
     }
 
+    tab.lastSelected = Date.now();
+
+    if (tab.scrollOffset) {
+      let [scrollX, scrollY] = tab.scrollOffset;
+      // Util.dumpf('Switch tab scrolls to: %s, %s\n', scrollX, scrollY);
+      Browser.contentScrollboxScroller.scrollTo(scrollX, scrollY);
+    }
+
     bv.commitBatchOperation();
   },
 
   supportsCommand: function(cmd) {
     var isSupported = false;
     switch (cmd) {
       case "cmd_fullscreen":
         isSupported = true;
@@ -790,16 +799,35 @@ var Browser = {
           node.parentNode.removeChild(node);
         } catch(e) {
           //do nothing, but continue
         }
       }
     }
   },
 
+  /** Returns true iff a tab's browser has been destroyed to free up memory. */
+  sacrificeTab: function sacrificeTab() {
+    let tabToClear = this._tabs.reduce(function(prevTab, currentTab) {
+      if (currentTab == Browser.selectedTab || !currentTab.browser) {
+        return prevTab;
+      } else {
+        return (prevTab && prevTab.lastSelected <= currentTab.lastSelected) ? prevTab : currentTab;
+      }
+    }, null);
+
+    if (tabToClear) {
+      tabToClear.saveState();
+      tabToClear._destroyBrowser();
+      return true;
+    } else {
+      return false;
+    }
+  },
+
   /**
    * Handle command event bubbling up from content.  This allows us to do chrome-
    * privileged things based on buttons in, e.g., unprivileged error pages.
    * Obviously, care should be taken not to trust events that web pages could have
    * synthesized.
    */
   _handleContentCommand: function _handleContentCommand(aEvent) {
     // Don't trust synthetic events
@@ -1976,16 +2004,25 @@ const gSessionHistoryObserver = {
     let urlbar = document.getElementById("urlbar-edit");
     if (urlbar) {
       // Clear undo history of the URL bar
       urlbar.editor.transactionManager.clear();
     }
   }
 };
 
+var MemoryObserver = {
+  observe: function() {
+    let memory = Cc["@mozilla.org/xpcom/memory-service;1"].getService(Ci.nsIMemory);
+    do {
+      Browser.windowUtils.garbageCollect();      
+    } while (memory.isLowMemory() && Browser.sacrificeTab());
+  }
+};
+
 #ifdef WINCE
 // Windows Mobile does not resize the window automatically when the soft
 // keyboard is displayed. Maemo does resize the window.
 var SoftKeyboardObserver = {
   observe: function sko_observe(subject, topic, data) {
     if (topic === "softkb-change") {
       // The rect passed to us is the space available to our window, so
       // let's use it to resize the main window
@@ -2298,42 +2335,57 @@ ProgressController.prototype = {
 function Tab() {
   this._id = null;
   this._browser = null;
   this._browserViewportState = null;
   this._state = null;
   this._listener = null;
   this._loading = false;
   this._chromeTab = null;
+
+  // Set to 0 since new tabs that have not been viewed yet are good tabs to
+  // toss if app needs more memory.
+  this.lastSelected = 0;
+
   this.create();
 }
 
 Tab.prototype = {
   get browser() {
     return this._browser;
   },
 
   get browserViewportState() {
     return this._browserViewportState;
   },
 
   get chromeTab() {
     return this._chromeTab;
   },
 
-
+  /**
+   * Throttles redraws to once every second while loading the page, zooming to fit page if
+   * user hasn't started zooming.
+   */
   _resizeAndPaint: function() {
     let bv = Browser._browserView;
 
     if (this == Browser.selectedTab) {
       // !!! --- RESIZE HACK BEGIN -----
       bv.simulateMozAfterSizeChange();
       // !!! --- RESIZE HACK END -----
 
-      bv.zoomToPage();
+      let restoringPage = (this._state != null);
+
+      if (!this._browserViewportState.zoomChanged && !restoringPage) {
+        // Only fit page if user hasn't started zooming around and this is a page that
+        // isn't being restored.
+        bv.zoomToPage();
+      }
+
     }
     bv.commitBatchOperation();
 
     if (this._loading) {
       // kick ourselves off 2s later while we're still loading
       bv.beginBatchOperation();
       this._loadingTimeout = setTimeout(Util.bind(this._resizeAndPaint, this), 2000);
     } else {
@@ -2341,56 +2393,72 @@ Tab.prototype = {
     }
   },
 
   startLoading: function() {
     if (this._loading)
       dump("!!! Already loading this tab, please file a bug\n");
 
     this._loading = true;
+    this._browserViewportState.zoomChanged = false;
 
     if (!this._loadingTimeout) {
       Browser._browserView.beginBatchOperation();
       this._loadingTimeout = setTimeout(Util.bind(this._resizeAndPaint, this), 2000);
     }
   },
 
   endLoading: function() {
     if (!this._loading)
       dump("!!! Already finished loading this tab, please file a bug\n");
 
     this._loading = false;
     clearTimeout(this._loadingTimeout);
+
     // in order to ensure we commit our current batch,
     // we need to run this function here
     this._resizeAndPaint();
+
+    // if this tab was sacrificed previously, restore its state
+    this.restoreState();
   },
 
   isLoading: function() {
     return this._loading;
   },
 
   load: function(uri) {
     this._browser.setAttribute("src", uri);
   },
 
   create: function() {
     this._chromeTab = document.createElement("richlistitem");
     this._chromeTab.setAttribute("type", "documenttab");
     document.getElementById("tabs").addTab(this._chromeTab);
 
+    // Initialize a viewport state for BrowserView
+    this._browserViewportState = BrowserView.Util.createBrowserViewportState();
+
     this._createBrowser();
   },
 
   destroy: function() {
     this._destroyBrowser();
     document.getElementById("tabs").removeTab(this._chromeTab);
     this._chromeTab = null;
   },
 
+  /** Create browser if it doesn't already exist. */
+  ensureBrowserExists: function() {
+    if (!this._browser) {
+      this._createBrowser();
+      this.browser.contentDocument.location = this._state._url;
+    }
+  },
+
   _createBrowser: function() {
     if (this._browser)
       throw "Browser already exists";
 
     // Create the browser using the current width the dynamically size the height
     let scaledHeight = kDefaultBrowserWidth * (window.innerHeight / window.innerWidth);
     let browser = this._browser = document.createElement("browser");
 
@@ -2405,38 +2473,36 @@ Tab.prototype = {
       browser.setAttribute("autocompletepopup", autocompletepopup);
 
     // Append the browser to the document, which should start the page load
     document.getElementById("browsers").appendChild(browser);
 
     // stop about:blank from loading
     browser.stop();
 
-    // Initialize a viewport state for BrowserView
-    let initVis = Browser.getVisibleRect();
-    initVis.x = 0;
-    initVis.y = 0;
-    this._browserViewportState = BrowserView.Util.createBrowserViewportState(browser, initVis);
-
     // Attach a separate progress listener to the browser
     this._listener = new ProgressController(this);
     browser.addProgressListener(this._listener);
   },
 
   _destroyBrowser: function() {
-    document.getElementById("browsers").removeChild(this._browser);
-    this._browser = null;
+    if (this._browser) {
+      document.getElementById("browsers").removeChild(this._browser);
+      this._browser = null;
+    }
   },
 
+  /** Serializes as much state as possible of the current content.  */
   saveState: function() {
     let state = { };
 
-    this._url = browser.contentWindow.location.toString();
-    var browser = this.getBrowserForDisplay(display);
+    var browser = this._browser;
     var doc = browser.contentDocument;
+    state._url = doc.location.href;
+    state._scroll = BrowserView.Util.getContentScrollValues(this.browser);
     if (doc instanceof HTMLDocument) {
       var tags = ["input", "textarea", "select"];
 
       for (var t = 0; t < tags.length; t++) {
         var elements = doc.getElementsByTagName(tags[t]);
         for (var e = 0; e < elements.length; e++) {
           var element = elements[e];
           var id;
@@ -2446,46 +2512,51 @@ Tab.prototype = {
             id = "$" + element.name;
 
           if (id)
             state[id] = element.value;
         }
       }
     }
 
-    state._scrollX = browser.contentWindow.scrollX;
-    state._scrollY = browser.contentWindow.scrollY;
-
     this._state = state;
   },
 
+  /** Restores serialized content from saveState.  */
   restoreState: function() {
     let state = this._state;
     if (!state)
       return;
 
     let doc = this._browser.contentDocument;
-    for (item in state) {
+
+    for (var item in state) {
       var elem = null;
       if (item.charAt(0) == "#") {
         elem = doc.getElementById(item.substring(1));
-      }
-      else if (item.charAt(0) == "$") {
+      } else if (item.charAt(0) == "$") {
         var list = doc.getElementsByName(item.substring(1));
         if (list.length)
           elem = list[0];
       }
 
       if (elem)
         elem.value = state[item];
     }
 
-    this._browser.contentWindow.scrollTo(state._scrollX, state._scrollY);
+    this.browser.contentWindow.scrollX = state._scroll[0];
+    this.browser.contentWindow.scrollY = state._scroll[1];
+
+    this._state = null;
   },
 
   updateThumbnail: function() {
     if (!this._browser)
       return;
 
     let browserView = (Browser.selectedBrowser == this._browser) ? Browser._browserView : null;
     this._chromeTab.updateThumbnail(this._browser, browserView);
+  },
+
+  toString: function() {
+    return "[Tab " + (this._browser ? this._browser.contentDocument.location.toString() : "(no browser)") + "]";
   }
 };