Bug 1077454 - Handle import/export in new performance tool, r=jsantell
authorVictor Porof <vporof@mozilla.com>
Tue, 23 Dec 2014 11:50:50 -0500
changeset 221103 4f32657399b69c4faef3fa84282574435451c968
parent 221102 e22433270ec30950336368a52425d138e9f4275c
child 221104 e5f550fa112c962f5d0d83fcf087386a6c377edb
push id28010
push userkwierso@gmail.com
push dateWed, 24 Dec 2014 00:29:29 +0000
treeherdermozilla-central@8b881bea204a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjsantell
bugs1077454
milestone37.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1077454 - Handle import/export in new performance tool, r=jsantell
browser/devtools/performance/modules/front.js
browser/devtools/performance/modules/io.js
browser/devtools/performance/moz.build
browser/devtools/performance/performance-controller.js
browser/devtools/performance/performance-view.js
browser/devtools/performance/performance.xul
browser/devtools/performance/test/browser.ini
browser/devtools/performance/test/browser_perf_recordings-io-01.js
browser/devtools/performance/test/browser_perf_recordings-io-02.js
browser/devtools/performance/test/browser_perf_recordings-io-03.js
browser/devtools/performance/test/head.js
browser/devtools/performance/views/details-call-tree.js
browser/devtools/performance/views/details-waterfall.js
browser/devtools/performance/views/details.js
browser/devtools/performance/views/overview.js
browser/devtools/shared/widgets/Graphs.jsm
browser/locales/en-US/chrome/browser/devtools/profiler.dtd
--- a/browser/devtools/performance/modules/front.js
+++ b/browser/devtools/performance/modules/front.js
@@ -1,31 +1,32 @@
 /* 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";
 
 const { Cc, Ci, Cu, Cr } = require("chrome");
+const { Task } = require("resource://gre/modules/Task.jsm");
 const { extend } = require("sdk/util/object");
-const { Task } = require("resource://gre/modules/Task.jsm");
 
 loader.lazyRequireGetter(this, "Services");
 loader.lazyRequireGetter(this, "promise");
 loader.lazyRequireGetter(this, "EventEmitter",
   "devtools/toolkit/event-emitter");
 loader.lazyRequireGetter(this, "TimelineFront",
   "devtools/server/actors/timeline", true);
 loader.lazyRequireGetter(this, "DevToolsUtils",
   "devtools/toolkit/DevToolsUtils");
 
 loader.lazyImporter(this, "gDevTools",
   "resource:///modules/devtools/gDevTools.jsm");
 
 /**
- * A cache of all PerformanceActorsConnection instances. The keys are Target objects.
+ * A cache of all PerformanceActorsConnection instances.
+ * The keys are Target objects.
  */
 let SharedPerformanceActors = new WeakMap();
 
 /**
  * Instantiates a shared PerformanceActorsConnection for the specified target.
  * Consumers must yield on `open` to make sure the connection is established.
  *
  * @param Target target
@@ -37,17 +38,17 @@ SharedPerformanceActors.forTarget = func
   }
 
   let instance = new PerformanceActorsConnection(target);
   this.set(target, instance);
   return instance;
 };
 
 /**
- * A connection to underlying actors (profiler, memory, framerate, etc)
+ * A connection to underlying actors (profiler, memory, framerate, etc.)
  * shared by all tools in a target.
  *
  * Use `SharedPerformanceActors.forTarget` to make sure you get the same
  * instance every time, and the `PerformanceFront` to start/stop recordings.
  *
  * @param Target target
  *        The target owning this connection.
  */
@@ -57,17 +58,16 @@ function PerformanceActorsConnection(tar
   this._target = target;
   this._client = this._target.client;
   this._request = this._request.bind(this);
 
   Services.obs.notifyObservers(null, "performance-actors-connection-created", null);
 }
 
 PerformanceActorsConnection.prototype = {
-
   /**
    * Initializes a connection to the profiler and other miscellaneous actors.
    * If already open, nothing happens.
    *
    * @return object
    *         A promise that is resolved once the connection is established.
    */
   open: Task.async(function*() {
@@ -219,32 +219,31 @@ PerformanceFront.prototype = {
   startRecording: Task.async(function*(options = {}) {
     let { isActive, currentTime } = yield this._request("profiler", "isActive");
 
     // Start the profiler only if it wasn't already active. The built-in
     // nsIPerformance module will be kept recording, because it's the same instance
     // for all targets and interacts with the whole platform, so we don't want
     // to affect other clients by stopping (or restarting) it.
     if (!isActive) {
-      // Extend the options so that protocol.js doesn't modify
-      // the source object.
-      let options = extend({}, this._customPerformanceOptions);
-      yield this._request("profiler", "startProfiler", options);
+      // Extend the profiler options so that protocol.js doesn't modify the original.
+      let profilerOptions = extend({}, this._customProfilerOptions);
+      yield this._request("profiler", "startProfiler", profilerOptions);
       this._profilingStartTime = 0;
       this.emit("profiler-activated");
     } else {
       this._profilingStartTime = currentTime;
       this.emit("profiler-already-active");
     }
 
     // The timeline actor is target-dependent, so just make sure
     // it's recording.
+    let startTime = yield this._request("timeline", "start", options);
 
-    // Return start time from timeline actor
-    let startTime = yield this._request("timeline", "start", options);
+    // Return only the start time from the timeline actor.
     return { startTime };
   }),
 
   /**
    * Manually ends the current recording session.
    *
    * @return object
    *         A promise that is resolved once recording has stopped,
@@ -268,17 +267,17 @@ PerformanceFront.prototype = {
   }),
 
   /**
    * Overrides the options sent to the built-in profiler module when activating,
    * such as the maximum entries count, the sampling interval etc.
    *
    * Used in tests and for older backend implementations.
    */
-  _customPerformanceOptions: {
+  _customProfilerOptions: {
     entries: 1000000,
     interval: 1,
     features: ["js"]
   }
 };
 
 /**
  * Filters all the samples in the provided profiler data to be more recent
new file mode 100644
--- /dev/null
+++ b/browser/devtools/performance/modules/io.js
@@ -0,0 +1,102 @@
+/* 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";
+
+const { Cc, Ci, Cu, Cr } = require("chrome");
+
+loader.lazyRequireGetter(this, "Services");
+loader.lazyRequireGetter(this, "promise");
+
+loader.lazyImporter(this, "FileUtils",
+  "resource://gre/modules/FileUtils.jsm");
+loader.lazyImporter(this, "NetUtil",
+  "resource://gre/modules/NetUtil.jsm");
+
+// This identifier string is used to tentatively ascertain whether or not
+// a JSON loaded from disk is actually something generated by this tool.
+// It isn't, of course, a definitive verification, but a Good Enough™
+// approximation before continuing the import. Don't localize this.
+const PERF_TOOL_SERIALIZER_IDENTIFIER = "Recorded Performance Data";
+const PERF_TOOL_SERIALIZER_VERSION = 1;
+
+/**
+ * Helpers for importing/exporting JSON.
+ */
+let PerformanceIO = {
+  /**
+   * Gets a nsIScriptableUnicodeConverter instance with a default UTF-8 charset.
+   * @return object
+   */
+  getUnicodeConverter: function() {
+    let className = "@mozilla.org/intl/scriptableunicodeconverter";
+    let converter = Cc[className].createInstance(Ci.nsIScriptableUnicodeConverter);
+    converter.charset = "UTF-8";
+    return converter;
+  },
+
+  /**
+   * Saves a recording as JSON to a file. The provided data is assumed to be
+   * acyclical, so that it can be properly serialized.
+   *
+   * @param object recordingData
+   *        The recording data to stream as JSON.
+   * @param nsILocalFile file
+   *        The file to stream the data into.
+   * @return object
+   *         A promise that is resolved once streaming finishes, or rejected
+   *         if there was an error.
+   */
+  saveRecordingToFile: function(recordingData, file) {
+    let deferred = promise.defer();
+
+    recordingData.fileType = PERF_TOOL_SERIALIZER_IDENTIFIER;
+    recordingData.version = PERF_TOOL_SERIALIZER_VERSION;
+
+    let string = JSON.stringify(recordingData);
+    let inputStream = this.getUnicodeConverter().convertToInputStream(string);
+    let outputStream = FileUtils.openSafeFileOutputStream(file);
+
+    NetUtil.asyncCopy(inputStream, outputStream, deferred.resolve);
+    return deferred.promise;
+  },
+
+  /**
+   * Loads a recording stored as JSON from a file.
+   *
+   * @param nsILocalFile file
+   *        The file to import the data from.
+   * @return object
+   *         A promise that is resolved once importing finishes, or rejected
+   *         if there was an error.
+   */
+  loadRecordingFromFile: function(file) {
+    let deferred = promise.defer();
+
+    let channel = NetUtil.newChannel(file);
+    channel.contentType = "text/plain";
+
+    NetUtil.asyncFetch(channel, (inputStream, status) => {
+      try {
+        let string = NetUtil.readInputStreamToString(inputStream, inputStream.available());
+        var recordingData = JSON.parse(string);
+      } catch (e) {
+        deferred.reject(new Error("Could not read recording data file."));
+        return;
+      }
+      if (recordingData.fileType != PERF_TOOL_SERIALIZER_IDENTIFIER) {
+        deferred.reject(new Error("Unrecognized recording data file."));
+        return;
+      }
+      if (recordingData.version != PERF_TOOL_SERIALIZER_VERSION) {
+        deferred.reject(new Error("Unsupported recording data file version."));
+        return;
+      }
+      deferred.resolve(recordingData);
+    });
+
+    return deferred.promise;
+  }
+};
+
+exports.PerformanceIO = PerformanceIO;
--- a/browser/devtools/performance/moz.build
+++ b/browser/devtools/performance/moz.build
@@ -1,11 +1,12 @@
 # vim: set filetype=python:
 # 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/.
 
 EXTRA_JS_MODULES.devtools.performance += [
     'modules/front.js',
+    'modules/io.js',
     'panel.js'
 ]
 
 BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
--- a/browser/devtools/performance/performance-controller.js
+++ b/browser/devtools/performance/performance-controller.js
@@ -11,19 +11,21 @@ Cu.import("resource://gre/modules/devtoo
 Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
 
 devtools.lazyRequireGetter(this, "Services");
 devtools.lazyRequireGetter(this, "promise");
 devtools.lazyRequireGetter(this, "EventEmitter",
   "devtools/toolkit/event-emitter");
 devtools.lazyRequireGetter(this, "DevToolsUtils",
   "devtools/toolkit/DevToolsUtils");
+
 devtools.lazyRequireGetter(this, "L10N",
   "devtools/profiler/global", true);
-
+devtools.lazyRequireGetter(this, "PerformanceIO",
+  "devtools/performance/io", true);
 devtools.lazyRequireGetter(this, "MarkersOverview",
   "devtools/timeline/markers-overview", true);
 devtools.lazyRequireGetter(this, "MemoryOverview",
   "devtools/timeline/memory-overview", true);
 devtools.lazyRequireGetter(this, "Waterfall",
   "devtools/timeline/waterfall", true);
 devtools.lazyRequireGetter(this, "MarkerDetails",
   "devtools/timeline/marker-details", true);
@@ -34,26 +36,34 @@ devtools.lazyRequireGetter(this, "Thread
 
 devtools.lazyImporter(this, "CanvasGraphUtils",
   "resource:///modules/devtools/Graphs.jsm");
 devtools.lazyImporter(this, "LineGraphWidget",
   "resource:///modules/devtools/Graphs.jsm");
 
 // Events emitted by various objects in the panel.
 const EVENTS = {
+  // Emitted by the PerformanceView on record button click
+  UI_START_RECORDING: "Performance:UI:StartRecording",
+  UI_STOP_RECORDING: "Performance:UI:StopRecording",
+
+  // Emitted by the PerformanceView on import or export button click
+  UI_IMPORT_RECORDING: "Performance:UI:ImportRecording",
+  UI_EXPORT_RECORDING: "Performance:UI:ExportRecording",
+
   // When a recording is started or stopped via the PerformanceController
   RECORDING_STARTED: "Performance:RecordingStarted",
   RECORDING_STOPPED: "Performance:RecordingStopped",
 
-  // When the PerformanceController has new recording data.
-  TIMELINE_DATA: "Performance:TimelineData",
+  // When a recording is imported or exported via the PerformanceController
+  RECORDING_IMPORTED: "Performance:RecordingImported",
+  RECORDING_EXPORTED: "Performance:RecordingExported",
 
-  // Emitted by the PerformanceView on record button click
-  UI_START_RECORDING: "Performance:UI:StartRecording",
-  UI_STOP_RECORDING: "Performance:UI:StopRecording",
+  // When the PerformanceController has new recording data
+  TIMELINE_DATA: "Performance:TimelineData",
 
   // Emitted by the OverviewView when more data has been rendered
   OVERVIEW_RENDERED: "Performance:UI:OverviewRendered",
   FRAMERATE_GRAPH_RENDERED: "Performance:UI:OverviewFramerateRendered",
   MARKERS_GRAPH_RENDERED: "Performance:UI:OverviewMarkersRendered",
   MEMORY_GRAPH_RENDERED: "Performance:UI:OverviewMemoryRendered",
 
   // Emitted by the OverviewView when a range has been selected in the graphs
@@ -70,16 +80,21 @@ const EVENTS = {
   // When a source is shown in the JavaScript Debugger at a specific location.
   SOURCE_SHOWN_IN_JS_DEBUGGER: "Performance:UI:SourceShownInJsDebugger",
   SOURCE_NOT_FOUND_IN_JS_DEBUGGER: "Performance:UI:SourceNotFoundInJsDebugger",
 
   // Emitted by the WaterfallView when it has been rendered
   WATERFALL_RENDERED: "Performance:UI:WaterfallRendered"
 };
 
+// Constant defining the end time for a recording that hasn't finished
+// or is not yet available.
+const RECORDING_IN_PROGRESS = -1;
+const RECORDING_UNAVAILABLE = null;
+
 /**
  * The current target and the profiler connection, set by this tool's host.
  */
 let gToolbox, gTarget, gFront;
 
 /**
  * Initializes the profiler controller and views.
  */
@@ -123,44 +138,54 @@ let PrefObserver = {
  * Functions handling target-related lifetime events and
  * UI interaction.
  */
 let PerformanceController = {
   /**
    * Permanent storage for the markers and the memory measurements streamed by
    * the backend, along with the start and end timestamps.
    */
-  _startTime: 0,
-  _endTime: 0,
+  _localStartTime: RECORDING_UNAVAILABLE,
+  _startTime: RECORDING_UNAVAILABLE,
+  _endTime: RECORDING_UNAVAILABLE,
   _markers: [],
   _memory: [],
   _ticks: [],
+  _profilerData: {},
 
   /**
    * Listen for events emitted by the current tab target and
    * main UI events.
    */
   initialize: function() {
     this.startRecording = this.startRecording.bind(this);
     this.stopRecording = this.stopRecording.bind(this);
+    this.importRecording = this.importRecording.bind(this);
+    this.exportRecording = this.exportRecording.bind(this);
     this._onTimelineData = this._onTimelineData.bind(this);
 
     PerformanceView.on(EVENTS.UI_START_RECORDING, this.startRecording);
     PerformanceView.on(EVENTS.UI_STOP_RECORDING, this.stopRecording);
+    PerformanceView.on(EVENTS.UI_EXPORT_RECORDING, this.exportRecording);
+    PerformanceView.on(EVENTS.UI_IMPORT_RECORDING, this.importRecording);
+
     gFront.on("ticks", this._onTimelineData); // framerate
     gFront.on("markers", this._onTimelineData); // timeline markers
     gFront.on("memory", this._onTimelineData); // timeline memory
   },
 
   /**
    * Remove events handled by the PerformanceController
    */
   destroy: function() {
     PerformanceView.off(EVENTS.UI_START_RECORDING, this.startRecording);
     PerformanceView.off(EVENTS.UI_STOP_RECORDING, this.stopRecording);
+    PerformanceView.off(EVENTS.UI_EXPORT_RECORDING, this.exportRecording);
+    PerformanceView.off(EVENTS.UI_IMPORT_RECORDING, this.importRecording);
+
     gFront.off("ticks", this._onTimelineData);
     gFront.off("markers", this._onTimelineData);
     gFront.off("memory", this._onTimelineData);
   },
 
   /**
    * Starts recording with the PerformanceFront. Emits `EVENTS.RECORDING_STARTED`
    * when the front has started to record.
@@ -173,51 +198,103 @@ let PerformanceController = {
     this._localStartTime = performance.now();
 
     let { startTime } = yield gFront.startRecording({
       withTicks: true,
       withMemory: true
     });
 
     this._startTime = startTime;
-    this._endTime = startTime;
+    this._endTime = RECORDING_IN_PROGRESS;
     this._markers = [];
     this._memory = [];
     this._ticks = [];
 
-    this.emit(EVENTS.RECORDING_STARTED, this._startTime);
+    this.emit(EVENTS.RECORDING_STARTED);
   }),
 
   /**
    * Stops recording with the PerformanceFront. Emits `EVENTS.RECORDING_STOPPED`
    * when the front has stopped recording.
    */
   stopRecording: Task.async(function *() {
     let results = yield gFront.stopRecording();
 
     // If `endTime` is not yielded from timeline actor (< Fx36), fake it.
     if (!results.endTime) {
-      results.endTime = this._startTime + this.getInterval().localElapsedTime;
+      results.endTime = this._startTime + this.getLocalElapsedTime();
     }
 
     this._endTime = results.endTime;
+    this._profilerData = results.profilerData;
     this._markers = this._markers.sort((a,b) => (a.start > b.start));
 
-    this.emit(EVENTS.RECORDING_STOPPED, results);
+    this.emit(EVENTS.RECORDING_STOPPED);
+  }),
+
+  /**
+   * Saves the current recording to a file.
+   *
+   * @param nsILocalFile file
+   *        The file to stream the data into.
+   */
+  exportRecording: Task.async(function*(_, file) {
+    let recordingData = this.getAllData();
+    yield PerformanceIO.saveRecordingToFile(recordingData, file);
+
+    this.emit(EVENTS.RECORDING_EXPORTED, recordingData);
   }),
 
   /**
+   * Loads a recording from a file, replacing the current one.
+   * XXX: Handle multiple recordings, bug 1111004.
+   *
+   * @param nsILocalFile file
+   *        The file to import the data from.
+   */
+  importRecording: Task.async(function*(_, file) {
+    let recordingData = yield PerformanceIO.loadRecordingFromFile(file);
+
+    this._startTime = recordingData.interval.startTime;
+    this._endTime = recordingData.interval.endTime;
+    this._markers = recordingData.markers;
+    this._memory = recordingData.memory;
+    this._ticks = recordingData.ticks;
+    this._profilerData = recordingData.profilerData;
+
+    this.emit(EVENTS.RECORDING_IMPORTED, recordingData);
+
+    // Flush the current recording.
+    this.emit(EVENTS.RECORDING_STARTED);
+    this.emit(EVENTS.RECORDING_STOPPED);
+  }),
+
+  /**
+   * Gets the amount of time elapsed locally after starting a recording.
+   */
+  getLocalElapsedTime: function() {
+    return performance.now() - this._localStartTime;
+  },
+
+  /**
    * Gets the time interval for the current recording.
    * @return object
    */
   getInterval: function() {
-    let localElapsedTime = performance.now() - this._localStartTime;
     let startTime = this._startTime;
     let endTime = this._endTime;
-    return { localElapsedTime, startTime, endTime };
+
+    // Compute an approximate ending time for the current recording. This is
+    // needed to ensure that the view updates even when new data is
+    // not being generated.
+    if (endTime == RECORDING_IN_PROGRESS) {
+      endTime = startTime + this.getLocalElapsedTime();
+    }
+
+    return { startTime, endTime };
   },
 
   /**
    * Gets the accumulated markers in the current recording.
    * @return array
    */
   getMarkers: function() {
     return this._markers;
@@ -235,16 +312,36 @@ let PerformanceController = {
    * Gets the accumulated refresh driver ticks in this recording.
    * @return array
    */
   getTicks: function() {
     return this._ticks;
   },
 
   /**
+   * Gets the profiler data in this recording.
+   * @return array
+   */
+  getProfilerData: function() {
+    return this._profilerData;
+  },
+
+  /**
+   * Gets all the data in this recording.
+   */
+  getAllData: function() {
+    let interval = this.getInterval();
+    let markers = this.getMarkers();
+    let memory = this.getMemory();
+    let ticks = this.getTicks();
+    let profilerData = this.getProfilerData();
+    return { interval, markers, memory, ticks, profilerData };
+  },
+
+  /**
    * Fired whenever the PerformanceFront emits markers, memory or ticks.
    */
   _onTimelineData: function (eventName, ...data) {
     // Accumulate markers into an array.
     if (eventName == "markers") {
       let [markers] = data;
       Array.prototype.push.apply(this._markers, markers);
     }
--- a/browser/devtools/performance/performance-view.js
+++ b/browser/devtools/performance/performance-view.js
@@ -7,38 +7,47 @@
  * Master view handler for the performance tool.
  */
 let PerformanceView = {
   /**
    * Sets up the view with event binding and main subviews.
    */
   initialize: function () {
     this._recordButton = $("#record-button");
+    this._importButton = $("#import-button");
+    this._exportButton = $("#export-button");
 
     this._onRecordButtonClick = this._onRecordButtonClick.bind(this);
+    this._onImportButtonClick = this._onImportButtonClick.bind(this);
+    this._onExportButtonClick = this._onExportButtonClick.bind(this);
     this._lockRecordButton = this._lockRecordButton.bind(this);
     this._unlockRecordButton = this._unlockRecordButton.bind(this);
 
     this._recordButton.addEventListener("click", this._onRecordButtonClick);
+    this._importButton.addEventListener("click", this._onImportButtonClick);
+    this._exportButton.addEventListener("click", this._onExportButtonClick);
 
     // Bind to controller events to unlock the record button
     PerformanceController.on(EVENTS.RECORDING_STARTED, this._unlockRecordButton);
     PerformanceController.on(EVENTS.RECORDING_STOPPED, this._unlockRecordButton);
 
     return promise.all([
       OverviewView.initialize(),
       DetailsView.initialize()
     ]);
   },
 
   /**
    * Unbinds events and destroys subviews.
    */
   destroy: function () {
     this._recordButton.removeEventListener("click", this._onRecordButtonClick);
+    this._importButton.removeEventListener("click", this._onImportButtonClick);
+    this._exportButton.removeEventListener("click", this._onExportButtonClick);
+
     PerformanceController.off(EVENTS.RECORDING_STARTED, this._unlockRecordButton);
     PerformanceController.off(EVENTS.RECORDING_STOPPED, this._unlockRecordButton);
 
     return promise.all([
       OverviewView.destroy(),
       DetailsView.destroy()
     ]);
   },
@@ -66,15 +75,46 @@ let PerformanceView = {
       this._recordButton.removeAttribute("checked");
       this._lockRecordButton();
       this.emit(EVENTS.UI_STOP_RECORDING);
     } else {
       this._recordButton.setAttribute("checked", "true");
       this._lockRecordButton();
       this.emit(EVENTS.UI_START_RECORDING);
     }
+  },
+
+  /**
+   * Handler for clicking the import button.
+   */
+  _onImportButtonClick: function(e) {
+    let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+    fp.init(window, L10N.getStr("recordingsList.saveDialogTitle"), Ci.nsIFilePicker.modeOpen);
+    fp.appendFilter(L10N.getStr("recordingsList.saveDialogJSONFilter"), "*.json");
+    fp.appendFilter(L10N.getStr("recordingsList.saveDialogAllFilter"), "*.*");
+
+    if (fp.show() == Ci.nsIFilePicker.returnOK) {
+      this.emit(EVENTS.UI_IMPORT_RECORDING, fp.file);
+    }
+  },
+
+  /**
+   * Handler for clicking the export button.
+   */
+  _onExportButtonClick: function(e) {
+    let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+    fp.init(window, L10N.getStr("recordingsList.saveDialogTitle"), Ci.nsIFilePicker.modeSave);
+    fp.appendFilter(L10N.getStr("recordingsList.saveDialogJSONFilter"), "*.json");
+    fp.appendFilter(L10N.getStr("recordingsList.saveDialogAllFilter"), "*.*");
+    fp.defaultString = "profile.json";
+
+    fp.open({ done: result => {
+      if (result != Ci.nsIFilePicker.returnCancel) {
+        this.emit(EVENTS.UI_EXPORT_RECORDING, fp.file);
+      }
+    }});
   }
 };
 
 /**
  * Convenient way of emitting events from the view.
  */
 EventEmitter.decorate(PerformanceView);
--- a/browser/devtools/performance/performance.xul
+++ b/browser/devtools/performance/performance.xul
@@ -31,16 +31,19 @@
                        class="devtools-toolbarbutton"
                        label="&profilerUI.clearButton;"/>
       </hbox>
       <spacer flex="1"></spacer>
       <hbox id="performance-toolbar-controls-storage" class="devtools-toolbarbutton-group">
         <toolbarbutton id="import-button"
                        class="devtools-toolbarbutton"
                        label="&profilerUI.importButton;"/>
+        <toolbarbutton id="export-button"
+                       class="devtools-toolbarbutton"
+                       label="&profilerUI.exportButton;"/>
       </hbox>
     </toolbar>
 
     <vbox id="overview-pane">
       <hbox id="time-framerate"/>
       <hbox id="markers-overview"/>
       <hbox id="memory-overview"/>
     </vbox>
--- a/browser/devtools/performance/test/browser.ini
+++ b/browser/devtools/performance/test/browser.ini
@@ -4,41 +4,37 @@ subsuite = devtools
 support-files =
   doc_simple-test.html
   head.js
 
 # Commented out tests are profiler tests
 # that need to be moved over to performance tool
 
 [browser_perf-aaa-run-first-leaktest.js]
-[browser_perf-front.js]
-[browser_perf-front-basic-timeline-01.js]
+[browser_perf-data-massaging-01.js]
+[browser_perf-data-samples.js]
+[browser_perf-details-calltree-render-01.js]
+[browser_perf-details-calltree-render-02.js]
+[browser_perf-details-waterfall-render-01.js]
+[browser_perf-details.js]
 [browser_perf-front-basic-profiler-01.js]
-# bug 1077464
-#[browser_perf-front-profiler-01.js]
+[browser_perf-front-basic-timeline-01.js]
+#[browser_perf-front-profiler-01.js] bug 1077464
 [browser_perf-front-profiler-02.js]
 [browser_perf-front-profiler-03.js]
 [browser_perf-front-profiler-04.js]
-# bug 1077464
-#[browser_perf-front-profiler-05.js]
-# bug 1077464
+#[browser_perf-front-profiler-05.js] bug 1077464
 #[browser_perf-front-profiler-06.js]
-# needs shared connection with profiler's shared connection
-#[browser_perf-shared-connection-01.js]
-[browser_perf-shared-connection-02.js]
-[browser_perf-shared-connection-03.js]
-# bug 1077464
-#[browser_perf-shared-connection-04.js]
-[browser_perf-data-samples.js]
-[browser_perf-data-massaging-01.js]
-[browser_perf-ui-recording.js]
+[browser_perf-front.js]
+[browser_perf-jump-to-debugger-01.js]
+[browser_perf-jump-to-debugger-02.js]
 [browser_perf-overview-render-01.js]
 [browser_perf-overview-render-02.js]
 [browser_perf-overview-selection-01.js]
 [browser_perf-overview-selection-02.js]
 [browser_perf-overview-selection-03.js]
-
-[browser_perf-details.js]
-[browser_perf-jump-to-debugger-01.js]
-[browser_perf-jump-to-debugger-02.js]
-[browser_perf-details-calltree-render-01.js]
-[browser_perf-details-calltree-render-02.js]
-[browser_perf-details-waterfall-render-01.js]
+[browser_perf-shared-connection-02.js]
+[browser_perf-shared-connection-03.js]
+# [browser_perf-shared-connection-04.js] bug 1077464
+[browser_perf-ui-recording.js]
+[browser_perf_recordings-io-01.js]
+[browser_perf_recordings-io-02.js]
+[browser_perf_recordings-io-03.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/performance/test/browser_perf_recordings-io-01.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if the performance tool is able to save and load recordings.
+ */
+
+let test = Task.async(function*() {
+  let { target, panel, toolbox } = yield initPerformance(SIMPLE_URL);
+  let { EVENTS, PerformanceController } = panel.panelWin;
+
+  yield startRecording(panel);
+  yield stopRecording(panel);
+
+  // Verify original recording.
+
+  let originalData = PerformanceController.getAllData();
+  ok(originalData, "The original recording is not empty.");
+
+  // Save recording.
+
+  let file = FileUtils.getFile("TmpD", ["tmpprofile.json"]);
+  file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("666", 8));
+
+  let exported = once(PerformanceController, EVENTS.RECORDING_EXPORTED);
+  yield PerformanceController.exportRecording("", file);
+
+  yield exported;
+  ok(true, "The recording data appears to have been successfully saved.");
+
+  // Import recording.
+
+  let rerendered = waitForWidgetsRendered(panel);
+  let imported = once(PerformanceController, EVENTS.RECORDING_IMPORTED);
+  yield PerformanceController.importRecording("", file);
+
+  yield imported;
+  ok(true, "The recording data appears to have been successfully imported.");
+
+  yield rerendered;
+  ok(true, "The imported data was re-rendered.");
+
+  // Verify imported recording.
+
+  let importedData = PerformanceController.getAllData();
+
+  is(importedData.startTime, originalData.startTime,
+    "The impored data is identical to the original data (1).");
+  is(importedData.endTime, originalData.endTime,
+    "The impored data is identical to the original data (2).");
+
+  is(importedData.markers.toSource(), originalData.markers.toSource(),
+    "The impored data is identical to the original data (3).");
+  is(importedData.memory.toSource(), originalData.memory.toSource(),
+    "The impored data is identical to the original data (4).");
+  is(importedData.ticks.toSource(), originalData.ticks.toSource(),
+    "The impored data is identical to the original data (5).");
+  is(importedData.profilerData.toSource(), originalData.profilerData.toSource(),
+    "The impored data is identical to the original data (6).");
+
+  yield teardown(panel);
+  finish();
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/performance/test/browser_perf_recordings-io-02.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if the performance tool gracefully handles loading bogus files.
+ */
+
+let test = Task.async(function*() {
+  let { target, panel, toolbox } = yield initPerformance(SIMPLE_URL);
+  let { EVENTS, PerformanceController } = panel.panelWin;
+
+  let file = FileUtils.getFile("TmpD", ["tmpprofile.json"]);
+  file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("666", 8));
+
+  try {
+    yield PerformanceController.importRecording("", file);
+    ok(false, "The recording succeeded unexpectedly.");
+  } catch (e) {
+    is(e.message, "Could not read recording data file.");
+    ok(true, "The recording was cancelled.");
+  }
+
+  yield teardown(panel);
+  finish();
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/performance/test/browser_perf_recordings-io-03.js
@@ -0,0 +1,54 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if the performance tool gracefully handles loading files that are JSON,
+ * but don't contain the appropriate recording data.
+ */
+
+let { FileUtils } = Cu.import("resource://gre/modules/FileUtils.jsm", {});
+let { NetUtil } = Cu.import("resource://gre/modules/NetUtil.jsm", {});
+
+let test = Task.async(function*() {
+  let { target, panel, toolbox } = yield initPerformance(SIMPLE_URL);
+  let { EVENTS, PerformanceController } = panel.panelWin;
+
+  let file = FileUtils.getFile("TmpD", ["tmpprofile.json"]);
+  file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("666", 8));
+  yield asyncCopy({ bogus: "data" }, file);
+
+  try {
+    yield PerformanceController.importRecording("", file);
+    ok(false, "The recording succeeded unexpectedly.");
+  } catch (e) {
+    is(e.message, "Unrecognized recording data file.");
+    ok(true, "The recording was cancelled.");
+  }
+
+  yield teardown(panel);
+  finish();
+});
+
+function getUnicodeConverter() {
+  let className = "@mozilla.org/intl/scriptableunicodeconverter";
+  let converter = Cc[className].createInstance(Ci.nsIScriptableUnicodeConverter);
+  converter.charset = "UTF-8";
+  return converter;
+}
+
+function asyncCopy(data, file) {
+  let deferred = Promise.defer();
+
+  let string = JSON.stringify(data);
+  let inputStream = getUnicodeConverter().convertToInputStream(string);
+  let outputStream = FileUtils.openSafeFileOutputStream(file);
+
+  NetUtil.asyncCopy(inputStream, outputStream, status => {
+    if (!Components.isSuccessCode(status)) {
+      deferred.reject(new Error("Could not save data to file."));
+    }
+    deferred.resolve();
+  });
+
+  return deferred.promise;
+}
--- a/browser/devtools/performance/test/head.js
+++ b/browser/devtools/performance/test/head.js
@@ -274,16 +274,29 @@ function* stopRecording(panel) {
   yield ended;
 
   ok(!button.hasAttribute("checked"),
     "The record button should not be checked.");
   ok(!button.hasAttribute("locked"),
     "The record button should not be locked.");
 }
 
+function waitForWidgetsRendered(panel) {
+  let { EVENTS, OverviewView, CallTreeView, WaterfallView } = panel.panelWin;
+
+  return Promise.all([
+    once(OverviewView, EVENTS.FRAMERATE_GRAPH_RENDERED),
+    once(OverviewView, EVENTS.MARKERS_GRAPH_RENDERED),
+    once(OverviewView, EVENTS.MEMORY_GRAPH_RENDERED),
+    once(OverviewView, EVENTS.OVERVIEW_RENDERED),
+    once(CallTreeView, EVENTS.CALL_TREE_RENDERED),
+    once(WaterfallView, EVENTS.WATERFALL_RENDERED)
+  ]);
+}
+
 /**
  * Waits until a predicate returns true.
  *
  * @param function predicate
  *        Invoked once in a while until it returns true.
  * @param number interval [optional]
  *        How often the predicate is invoked, in milliseconds.
  */
--- a/browser/devtools/performance/views/details-call-tree.js
+++ b/browser/devtools/performance/views/details-call-tree.js
@@ -7,59 +7,64 @@
  * CallTree view containing profiler call tree, controlled by DetailsView.
  */
 let CallTreeView = {
   /**
    * Sets up the view with event binding.
    */
   initialize: function () {
     this._callTree = $(".call-tree-cells-container");
+    this._onRecordingStopped = this._onRecordingStopped.bind(this);
     this._onRangeChange = this._onRangeChange.bind(this);
     this._onLink = this._onLink.bind(this);
-    this._stop = this._stop.bind(this);
 
+    PerformanceController.on(EVENTS.RECORDING_STOPPED, this._onRecordingStopped);
     OverviewView.on(EVENTS.OVERVIEW_RANGE_SELECTED, this._onRangeChange);
     OverviewView.on(EVENTS.OVERVIEW_RANGE_CLEARED, this._onRangeChange);
-    PerformanceController.on(EVENTS.RECORDING_STOPPED, this._stop);
   },
 
   /**
    * Unbinds events.
    */
   destroy: function () {
+    PerformanceController.off(EVENTS.RECORDING_STOPPED, this._onRecordingStopped);
     OverviewView.off(EVENTS.OVERVIEW_RANGE_SELECTED, this._onRangeChange);
     OverviewView.off(EVENTS.OVERVIEW_RANGE_CLEARED, this._onRangeChange);
-    PerformanceController.off(EVENTS.RECORDING_STOPPED, this._stop);
   },
 
   /**
    * Method for handling all the set up for rendering a new call tree.
    */
   render: function (profilerData, beginAt, endAt, options={}) {
+    // Empty recordings might yield no profiler data.
+    if (profilerData.profile == null) {
+      return;
+    }
     let threadNode = this._prepareCallTree(profilerData, beginAt, endAt, options);
     this._populateCallTree(threadNode, options);
     this.emit(EVENTS.CALL_TREE_RENDERED);
   },
 
   /**
    * Called when recording is stopped.
    */
-  _stop: function (_, { profilerData }) {
-    this._profilerData = profilerData;
+  _onRecordingStopped: function () {
+    let profilerData = PerformanceController.getProfilerData();
     this.render(profilerData);
   },
 
   /**
    * Fired when a range is selected or cleared in the OverviewView.
    */
   _onRangeChange: function (_, params) {
     // When a range is cleared, we'll have no beginAt/endAt data,
     // so the rebuild will just render all the data again.
+    let profilerData = PerformanceController.getProfilerData();
     let { beginAt, endAt } = params || {};
-    this.render(this._profilerData, beginAt, endAt);
+    this.render(profilerData, beginAt, endAt);
   },
 
   /**
    * Fired on the "link" event for the call tree in this container.
    */
   _onLink: function (_, treeItem) {
     let { url, line } = treeItem.frame.getInfo();
     viewSourceInDebugger(url, line).then(
@@ -117,23 +122,24 @@ EventEmitter.decorate(CallTreeView);
  * @param string url
  * @param number line
  */
 let viewSourceInDebugger = Task.async(function *(url, line) {
   // If the Debugger was already open, switch to it and try to show the
   // source immediately. Otherwise, initialize it and wait for the sources
   // to be added first.
   let debuggerAlreadyOpen = gToolbox.getPanel("jsdebugger");
-
   let { panelWin: dbg } = yield gToolbox.selectTool("jsdebugger");
 
   if (!debuggerAlreadyOpen) {
-    yield new Promise((resolve) => dbg.once(dbg.EVENTS.SOURCES_ADDED, () => resolve(dbg)));
+    yield dbg.once(dbg.EVENTS.SOURCES_ADDED);
   }
 
   let { DebuggerView } = dbg;
-  let item = DebuggerView.Sources.getItemForAttachment(a => a.source.url === url);
+  let { Sources } = DebuggerView;
 
+  let item = Sources.getItemForAttachment(a => a.source.url === url);
   if (item) {
     return DebuggerView.setEditorLocation(item.attachment.source.actor, line, { noDebug: true });
   }
-  return Promise.reject();
+
+  return Promise.reject("Couldn't find the specified source in the debugger.");
 });
--- a/browser/devtools/performance/views/details-waterfall.js
+++ b/browser/devtools/performance/views/details-waterfall.js
@@ -6,68 +6,68 @@
 /**
  * Waterfall view containing the timeline markers, controlled by DetailsView.
  */
 let WaterfallView = {
   /**
    * Sets up the view with event binding.
    */
   initialize: Task.async(function *() {
-    this._start = this._start.bind(this);
-    this._stop = this._stop.bind(this);
+    this._onRecordingStarted = this._onRecordingStarted.bind(this);
+    this._onRecordingStopped = this._onRecordingStopped.bind(this);
     this._onMarkerSelected = this._onMarkerSelected.bind(this);
     this._onResize = this._onResize.bind(this);
 
     this.graph = new Waterfall($("#waterfall-graph"), $("#details-pane"));
     this.markerDetails = new MarkerDetails($("#waterfall-details"), $("#waterfall-view > splitter"));
 
     this.graph.on("selected", this._onMarkerSelected);
     this.graph.on("unselected", this._onMarkerSelected);
     this.markerDetails.on("resize", this._onResize);
 
-    PerformanceController.on(EVENTS.RECORDING_STARTED, this._start);
-    PerformanceController.on(EVENTS.RECORDING_STOPPED, this._stop);
+    PerformanceController.on(EVENTS.RECORDING_STARTED, this._onRecordingStarted);
+    PerformanceController.on(EVENTS.RECORDING_STOPPED, this._onRecordingStopped);
 
     this.graph.recalculateBounds();
   }),
 
   /**
    * Unbinds events.
    */
   destroy: function () {
     this.graph.off("selected", this._onMarkerSelected);
     this.graph.off("unselected", this._onMarkerSelected);
     this.markerDetails.off("resize", this._onResize);
 
-    PerformanceController.off(EVENTS.RECORDING_STARTED, this._start);
-    PerformanceController.off(EVENTS.RECORDING_STOPPED, this._stop);
+    PerformanceController.off(EVENTS.RECORDING_STARTED, this._onRecordingStarted);
+    PerformanceController.off(EVENTS.RECORDING_STOPPED, this._onRecordingStopped);
   },
 
   /**
    * Method for handling all the set up for rendering a new waterfall.
    */
   render: function() {
     let { startTime, endTime } = PerformanceController.getInterval();
     let markers = PerformanceController.getMarkers();
 
     this.graph.setData(markers, startTime, startTime, endTime);
     this.emit(EVENTS.WATERFALL_RENDERED);
   },
 
   /**
    * Called when recording starts.
    */
-  _start: function (_, { startTime }) {
+  _onRecordingStarted: function () {
     this.graph.clearView();
   },
 
   /**
    * Called when recording stops.
    */
-  _stop: function (_, { endTime }) {
+  _onRecordingStopped: function () {
     this.render();
   },
 
   /**
    * Called when a marker is selected in the waterfall view,
    * updating the markers detail view.
    */
   _onMarkerSelected: function (event, marker) {
@@ -83,13 +83,12 @@ let WaterfallView = {
    * Called when the marker details view is resized.
    */
   _onResize: function () {
     this.graph.recalculateBounds();
     this.render();
   }
 };
 
-
 /**
  * Convenient way of emitting events from the view.
  */
 EventEmitter.decorate(WaterfallView);
--- a/browser/devtools/performance/views/details.js
+++ b/browser/devtools/performance/views/details.js
@@ -27,16 +27,17 @@ let DetailsView = {
     this._onViewToggle = this._onViewToggle.bind(this);
 
     for (let button of $$("toolbarbutton[data-view]", $("#details-toolbar"))) {
       button.addEventListener("command", this._onViewToggle);
     }
 
     yield CallTreeView.initialize();
     yield WaterfallView.initialize();
+
     this.selectView(DEFAULT_DETAILS_SUBVIEW);
   }),
 
   /**
    * Unbinds events, destroys subviews.
    */
   destroy: Task.async(function *() {
     for (let button of $$("toolbarbutton[data-view]", $("#details-toolbar"))) {
--- a/browser/devtools/performance/views/overview.js
+++ b/browser/devtools/performance/views/overview.js
@@ -16,58 +16,58 @@ const MARKERS_GRAPH_HEADER_HEIGHT = 12; 
 const MARKERS_GRAPH_BODY_HEIGHT = 45; // 9px * 5 groups
 const MARKERS_GROUP_VERTICAL_PADDING = 3.5; // px
 const MEMORY_GRAPH_HEIGHT = 30; // px
 
 const GRAPH_SCROLL_EVENTS_DRAIN = 50; // ms
 
 /**
  * View handler for the overview panel's time view, displaying
- * framerate over time.
+ * framerate, markers and memory over time.
  */
 let OverviewView = {
   /**
    * Sets up the view with event binding.
    */
   initialize: Task.async(function *() {
-    this._start = this._start.bind(this);
-    this._stop = this._stop.bind(this);
+    this._onRecordingStarted = this._onRecordingStarted.bind(this);
+    this._onRecordingStopped = this._onRecordingStopped.bind(this);
     this._onRecordingTick = this._onRecordingTick.bind(this);
     this._onGraphMouseUp = this._onGraphMouseUp.bind(this);
     this._onGraphScroll = this._onGraphScroll.bind(this);
 
     yield this._showFramerateGraph();
     yield this._showMarkersGraph();
     yield this._showMemoryGraph();
 
     this.framerateGraph.on("mouseup", this._onGraphMouseUp);
     this.framerateGraph.on("scroll", this._onGraphScroll);
     this.markersOverview.on("mouseup", this._onGraphMouseUp);
     this.markersOverview.on("scroll", this._onGraphScroll);
     this.memoryOverview.on("mouseup", this._onGraphMouseUp);
     this.memoryOverview.on("scroll", this._onGraphScroll);
 
-    PerformanceController.on(EVENTS.RECORDING_STARTED, this._start);
-    PerformanceController.on(EVENTS.RECORDING_STOPPED, this._stop);
+    PerformanceController.on(EVENTS.RECORDING_STARTED, this._onRecordingStarted);
+    PerformanceController.on(EVENTS.RECORDING_STOPPED, this._onRecordingStopped);
   }),
 
   /**
    * Unbinds events.
    */
   destroy: function () {
     this.framerateGraph.off("mouseup", this._onGraphMouseUp);
     this.framerateGraph.off("scroll", this._onGraphScroll);
     this.markersOverview.off("mouseup", this._onGraphMouseUp);
     this.markersOverview.off("scroll", this._onGraphScroll);
     this.memoryOverview.off("mouseup", this._onGraphMouseUp);
     this.memoryOverview.off("scroll", this._onGraphScroll);
 
     clearNamedTimeout("graph-scroll");
-    PerformanceController.off(EVENTS.RECORDING_STARTED, this._start);
-    PerformanceController.off(EVENTS.RECORDING_STOPPED, this._stop);
+    PerformanceController.off(EVENTS.RECORDING_STARTED, this._onRecordingStarted);
+    PerformanceController.off(EVENTS.RECORDING_STOPPED, this._onRecordingStopped);
   },
 
   /**
    * Sets up the framerate graph.
    */
   _showFramerateGraph: Task.async(function *() {
     this.framerateGraph = new LineGraphWidget($("#time-framerate"), L10N.getStr("graphs.fps"));
     this.framerateGraph.fixedHeight = FRAMERATE_GRAPH_HEIGHT;
@@ -107,38 +107,32 @@ let OverviewView = {
    *        The fps graph resolution. @see Graphs.jsm
    */
   render: Task.async(function *(resolution) {
     let interval = PerformanceController.getInterval();
     let markers = PerformanceController.getMarkers();
     let memory = PerformanceController.getMemory();
     let timestamps = PerformanceController.getTicks();
 
-    // Compute an approximate ending time for the view. This is
-    // needed to ensure that the view updates even when new data is
-    // not being generated.
-    let fakeTime = interval.startTime + interval.localElapsedTime;
-    interval.endTime = fakeTime;
-
     this.markersOverview.setData({ interval, markers });
     this.emit(EVENTS.MARKERS_GRAPH_RENDERED);
 
     this.memoryOverview.setData({ interval, memory });
     this.emit(EVENTS.MEMORY_GRAPH_RENDERED);
 
     yield this.framerateGraph.setDataFromTimestamps(timestamps, resolution);
     this.emit(EVENTS.FRAMERATE_GRAPH_RENDERED);
 
     // Finished rendering all graphs in this overview.
     this.emit(EVENTS.OVERVIEW_RENDERED);
   }),
 
   /**
    * Called at most every OVERVIEW_UPDATE_INTERVAL milliseconds
-   * and uses data fetched from `_onTimelineData` to render
+   * and uses data fetched from the controller to render
    * data into all the corresponding overview graphs.
    */
   _onRecordingTick: Task.async(function *() {
     yield this.render(FRAMERATE_GRAPH_LOW_RES_INTERVAL);
     this._prepareNextTick();
   }),
 
   /**
@@ -162,17 +156,17 @@ let OverviewView = {
     // Only fire a selection change event if the selection is actually enabled.
     if (this.framerateGraph.selectionEnabled) {
       this._onSelectionChange();
     }
   },
 
   /**
    * Listener handling the "scroll" event for the framerate graph.
-   * Fires an event to be handled elsewhere.
+   * Fires a debounced event to be handled elsewhere.
    */
   _onGraphScroll: function () {
     setNamedTimeout("graph-scroll", GRAPH_SCROLL_EVENTS_DRAIN, () => {
       this._onSelectionChange();
     });
   },
 
   /**
@@ -184,29 +178,29 @@ let OverviewView = {
     if (this._timeoutId) {
       this._timeoutId = setTimeout(this._onRecordingTick, OVERVIEW_UPDATE_INTERVAL);
     }
   },
 
   /**
    * Called when recording starts.
    */
-  _start: function () {
+  _onRecordingStarted: function () {
     this._timeoutId = setTimeout(this._onRecordingTick, OVERVIEW_UPDATE_INTERVAL);
 
     this.framerateGraph.dropSelection();
     this.framerateGraph.selectionEnabled = false;
     this.markersOverview.selectionEnabled = false;
     this.memoryOverview.selectionEnabled = false;
   },
 
   /**
    * Called when recording stops.
    */
-  _stop: function () {
+  _onRecordingStopped: function () {
     clearTimeout(this._timeoutId);
     this._timeoutId = null;
 
     this.render(FRAMERATE_GRAPH_HIGH_RES_INTERVAL);
 
     this.framerateGraph.selectionEnabled = true;
     this.markersOverview.selectionEnabled = true;
     this.memoryOverview.selectionEnabled = true;
--- a/browser/devtools/shared/widgets/Graphs.jsm
+++ b/browser/devtools/shared/widgets/Graphs.jsm
@@ -2096,17 +2096,21 @@ this.CanvasGraphUtils = {
 };
 
 /**
  * Maps a value from one range to another.
  * @param number value, istart, istop, ostart, ostop
  * @return number
  */
 function map(value, istart, istop, ostart, ostop) {
-  return ostart + (ostop - ostart) * ((value - istart) / (istop - istart));
+  let ratio = istop - istart;
+  if (ratio == 0) {
+    return value;
+  }
+  return ostart + (ostop - ostart) * ((value - istart) / ratio);
 }
 
 /**
  * Constrains a value to a range.
  * @param number value, min, max
  * @return number
  */
 function clamp(value, min, max) {
--- a/browser/locales/en-US/chrome/browser/devtools/profiler.dtd
+++ b/browser/locales/en-US/chrome/browser/devtools/profiler.dtd
@@ -28,16 +28,20 @@
 <!-- LOCALIZATION NOTE (profilerUI.recordButton): This string is displayed
   -  on a button that starts a new profile. -->
 <!ENTITY profilerUI.recordButton.tooltip "Record JavaScript function calls.">
 
 <!-- LOCALIZATION NOTE (profilerUI.importButton): This string is displayed
   -  on a button that opens a dialog to import a saved profile data file. -->
 <!ENTITY profilerUI.importButton "Import…">
 
+<!-- LOCALIZATION NOTE (profilerUI.exportButton): This string is displayed
+  -  on a button that opens a dialog to export a saved profile data file. -->
+<!ENTITY profilerUI.exportButton "Save">
+
 <!-- LOCALIZATION NOTE (profilerUI.clearButton): This string is displayed
   -  on a button that remvoes all the recordings. -->
 <!ENTITY profilerUI.clearButton "Clear">
 
 <!-- LOCALIZATION NOTE (profilerUI.invertTree): This is the label shown next to
   -  a checkbox that inverts and un-inverts the profiler's call tree. -->
 <!ENTITY profilerUI.invertTree "Invert Call Tree">