From ea433657517f7829bbbdf1f655c1ad3dd8ceeacc Mon Sep 17 00:00:00 2001 From: Jordan Santell Date: Thu, 5 Nov 2015 17:06:14 -0800 Subject: [PATCH] Bug 1215954 - Add feature to save a heap snapshot from memory tool to disk. r=fitzgen,vp --- .../client/locales/en-US/memory.properties | 12 +++++ devtools/client/memory/actions/io.js | 45 +++++++++++++++++++ devtools/client/memory/actions/moz.build | 1 + devtools/client/memory/app.js | 4 +- devtools/client/memory/components/list.js | 4 +- .../memory/components/snapshot-list-item.js | 15 +++++-- devtools/client/memory/constants.js | 6 +++ devtools/client/memory/store.js | 18 +++++++- devtools/client/memory/test/unit/head.js | 21 +++++++++ .../test/unit/test_action-export-snapshot.js | 43 ++++++++++++++++++ .../client/memory/test/unit/test_utils.js | 2 +- devtools/client/memory/test/unit/xpcshell.ini | 1 + devtools/client/memory/utils.js | 39 +++++++++++++++- devtools/client/shared/redux/create-store.js | 13 ++++-- .../client/shared/redux/middleware/history.js | 23 ++++++++++ .../client/shared/redux/middleware/moz.build | 1 + devtools/client/themes/memory.css | 12 ++++- 17 files changed, 246 insertions(+), 14 deletions(-) create mode 100644 devtools/client/memory/actions/io.js create mode 100644 devtools/client/memory/test/unit/test_action-export-snapshot.js create mode 100644 devtools/client/shared/redux/middleware/history.js diff --git a/devtools/client/locales/en-US/memory.properties b/devtools/client/locales/en-US/memory.properties index e4dcde4751a..61f6a45b905 100644 --- a/devtools/client/locales/en-US/memory.properties +++ b/devtools/client/locales/en-US/memory.properties @@ -24,6 +24,18 @@ memory.panelLabel=Memory Panel # displayed inside the developer tools window. memory.tooltip=Memory +# LOCALIZATION NOTE (snapshot.io.save): The label for the link that saves a snapshot +# to disk. +snapshot.io.save=Save + +# LOCALIZATION NOTE (snapshot.io.save.window): The title for the window displayed when +# saving a snapshot to disk. +snapshot.io.save.window=Save Heap Snapshot + +# LOCALIZATION NOTE (snapshot.io.filter): The title for the filter used to +# filter file types (*.fxsnapshot) +snapshot.io.filter=Firefox Heap Snapshots + # LOCALIZATION NOTE (aggregate.mb): The label annotating the number of bytes (in megabytes) # in a snapshot. %S represents the value, rounded to 2 decimal points. aggregate.mb=%S MB diff --git a/devtools/client/memory/actions/io.js b/devtools/client/memory/actions/io.js new file mode 100644 index 00000000000..89bea8ccffb --- /dev/null +++ b/devtools/client/memory/actions/io.js @@ -0,0 +1,45 @@ +/* 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 { assert } = require("devtools/shared/DevToolsUtils"); +const { snapshotState: states, actions } = require("../constants"); +const { L10N, openFilePicker } = require("../utils"); +const { OS } = require("resource://gre/modules/osfile.jsm"); +const VALID_EXPORT_STATES = [states.SAVED, states.READ, states.SAVING_CENSUS, states.SAVED_CENSUS]; + +exports.pickFileAndExportSnapshot = function (snapshot) { + return function* (dispatch, getState) { + let outputFile = yield openFilePicker({ + title: L10N.getFormatStr("snapshot.io.save.window"), + defaultName: OS.Path.basename(snapshot.path), + filters: [[L10N.getFormatStr("snapshot.io.filter"), "*.fxsnapshot"]] + }); + + if (!outputFile) { + return; + } + + yield dispatch(exportSnapshot(snapshot, outputFile.path)); + }; +}; + +const exportSnapshot = exports.exportSnapshot = function (snapshot, dest) { + return function* (dispatch, getState) { + + dispatch({ type: actions.EXPORT_SNAPSHOT_START, snapshot }); + + assert(VALID_EXPORT_STATES.includes(snapshot.state), + `Snapshot is in invalid state for exporting: ${snapshot.state}`); + + try { + yield OS.File.copy(snapshot.path, dest); + } catch (error) { + reportException("exportSnapshot", error); + dispatch({ type: actions.EXPORT_SNAPSHOT_ERROR, snapshot, error }); + } + + dispatch({ type: actions.EXPORT_SNAPSHOT_END, snapshot }); + }; +}; diff --git a/devtools/client/memory/actions/moz.build b/devtools/client/memory/actions/moz.build index 1fb9f9503b4..83ca635febd 100644 --- a/devtools/client/memory/actions/moz.build +++ b/devtools/client/memory/actions/moz.build @@ -8,5 +8,6 @@ DevToolsModules( 'breakdown.js', 'filter.js', 'inverted.js', + 'io.js', 'snapshot.js', ) diff --git a/devtools/client/memory/app.js b/devtools/client/memory/app.js index 454153dbda3..87816414609 100644 --- a/devtools/client/memory/app.js +++ b/devtools/client/memory/app.js @@ -9,6 +9,7 @@ const { toggleRecordingAllocationStacks } = require("./actions/allocations"); const { setBreakdownAndRefresh } = require("./actions/breakdown"); const { toggleInvertedAndRefresh } = require("./actions/inverted"); const { setFilterStringAndRefresh } = require("./actions/filter"); +const { pickFileAndExportSnapshot } = require("./actions/io"); const { selectSnapshotAndRefresh, takeSnapshotAndCensus } = require("./actions/snapshot"); const { breakdownNameToSpec, getBreakdownDisplayData } = require("./utils"); const Toolbar = createFactory(require("./components/toolbar")); @@ -78,7 +79,8 @@ const App = createClass({ List({ itemComponent: SnapshotListItem, items: snapshots, - onClick: snapshot => dispatch(selectSnapshotAndRefresh(heapWorker, snapshot)) + onClick: snapshot => dispatch(selectSnapshotAndRefresh(heapWorker, snapshot)), + onSave: snapshot => dispatch(pickFileAndExportSnapshot(snapshot)) }), HeapView({ diff --git a/devtools/client/memory/components/list.js b/devtools/client/memory/components/list.js index ee78f70fa2b..cbb2f0c2ba0 100644 --- a/devtools/client/memory/components/list.js +++ b/devtools/client/memory/components/list.js @@ -23,9 +23,9 @@ const List = module.exports = createClass({ return ( dom.ul({ className: "list" }, ...items.map((item, index) => { - return Item({ + return Item(Object.assign({}, this.props, { key: index, item, index, onClick: () => onClick(item), - }); + })); })) ); } diff --git a/devtools/client/memory/components/snapshot-list-item.js b/devtools/client/memory/components/snapshot-list-item.js index f5552274e58..2152b944ccc 100644 --- a/devtools/client/memory/components/snapshot-list-item.js +++ b/devtools/client/memory/components/snapshot-list-item.js @@ -11,13 +11,14 @@ const SnapshotListItem = module.exports = createClass({ displayName: "snapshot-list-item", propTypes: { - onClick: PropTypes.func, + onClick: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, item: snapshotModel.isRequired, index: PropTypes.number.isRequired, }, render() { - let { index, item: snapshot, onClick } = this.props; + let { index, item: snapshot, onClick, onSave } = this.props; let className = `snapshot-list-item ${snapshot.selected ? " selected" : ""}`; let statusText = getSnapshotStatusText(snapshot); let title = getSnapshotTitle(snapshot); @@ -34,12 +35,20 @@ const SnapshotListItem = module.exports = createClass({ details = dom.span({ className: "snapshot-state" }, statusText); } + let saveLink = !snapshot.path ? void 0 : dom.a({ + onClick: () => onSave(snapshot), + className: "save", + }, L10N.getFormatStr("snapshot.io.save")); + return ( dom.li({ className, onClick }, dom.span({ className: `snapshot-title ${statusText ? " devtools-throbber" : ""}` }, title), - details + dom.div({ className: "snapshot-info" }, + details, + saveLink + ) ) ); } diff --git a/devtools/client/memory/constants.js b/devtools/client/memory/constants.js index c6f90a1ab56..3e23301c23e 100644 --- a/devtools/client/memory/constants.js +++ b/devtools/client/memory/constants.js @@ -23,6 +23,12 @@ actions.TAKE_CENSUS_END = "take-census-end"; actions.TOGGLE_RECORD_ALLOCATION_STACKS_START = "toggle-record-allocation-stacks-start"; actions.TOGGLE_RECORD_ALLOCATION_STACKS_END = "toggle-record-allocation-stacks-end"; +// When a heap snapshot is being saved to a user-specified +// location on disk. +actions.EXPORT_SNAPSHOT_START = "export-snapshot-start"; +actions.EXPORT_SNAPSHOT_END = "export-snapshot-end"; +actions.EXPORT_SNAPSHOT_ERROR = "export-snapshot-error"; + // Fired by UI to select a snapshot to view. actions.SELECT_SNAPSHOT = "select-snapshot"; diff --git a/devtools/client/memory/store.js b/devtools/client/memory/store.js index 485266f136d..2bcd2ee56cd 100644 --- a/devtools/client/memory/store.js +++ b/devtools/client/memory/store.js @@ -8,6 +8,20 @@ const reducers = require("./reducers"); const DevToolsUtils = require("devtools/shared/DevToolsUtils"); module.exports = function () { - let shouldLog = DevToolsUtils.testing; - return createStore({ log: shouldLog })(combineReducers(reducers), {}); + let shouldLog = false; + let history; + + // If testing, store the action history in an array + // we'll later attach to the store + if (DevToolsUtils.testing) { + history = []; + } + + let store = createStore({ log: shouldLog, history })(combineReducers(reducers), {}); + + if (history) { + store.history = history; + } + + return store; }; diff --git a/devtools/client/memory/test/unit/head.js b/devtools/client/memory/test/unit/head.js index 523425959fa..c33f4720927 100644 --- a/devtools/client/memory/test/unit/head.js +++ b/devtools/client/memory/test/unit/head.js @@ -8,6 +8,8 @@ var { Services } = Cu.import("resource://gre/modules/Services.jsm", {}); var { gDevTools } = Cu.import("resource://devtools/client/framework/gDevTools.jsm", {}); var { console } = Cu.import("resource://gre/modules/Console.jsm", {}); var { require } = Cu.import("resource://devtools/shared/Loader.jsm", {}); +var { OS } = require("resource://gre/modules/osfile.jsm"); +var { FileUtils } = require("resource://gre/modules/FileUtils.jsm"); var { TargetFactory } = require("devtools/client/framework/target"); var DevToolsUtils = require("devtools/shared/DevToolsUtils"); var promise = require("promise"); @@ -70,6 +72,25 @@ function waitUntilState (store, predicate) { return deferred.promise; } +function waitUntilAction (store, actionType) { + let deferred = promise.defer(); + let unsubscribe = store.subscribe(check); + let history = store.history; + let index = history.length; + + do_print(`Waiting for action "${actionType}"`); + function check () { + let action = history[index++]; + if (action && action.type === actionType) { + do_print(`Found action "${actionType}"`); + unsubscribe(); + deferred.resolve(store.getState()); + } + } + + return deferred.promise; +} + function waitUntilSnapshotState (store, expected) { let predicate = () => { let snapshots = store.getState().snapshots; diff --git a/devtools/client/memory/test/unit/test_action-export-snapshot.js b/devtools/client/memory/test/unit/test_action-export-snapshot.js new file mode 100644 index 00000000000..22b19b29f0b --- /dev/null +++ b/devtools/client/memory/test/unit/test_action-export-snapshot.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test exporting a snapshot to a user specified location on disk. + +let { exportSnapshot } = require("devtools/client/memory/actions/io"); +let { takeSnapshotAndCensus } = require("devtools/client/memory/actions/snapshot"); +let { snapshotState: states, actions } = require("devtools/client/memory/constants"); + +function run_test() { + run_next_test(); +} + +add_task(function *() { + let front = new StubbedMemoryFront(); + let heapWorker = new HeapAnalysesClient(); + yield front.attach(); + let store = Store(); + const { getState, dispatch } = store; + + let file = FileUtils.getFile("TmpD", ["tmp.fxsnapshot"]); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + let destPath = file.path; + let stat = yield OS.File.stat(destPath); + ok(stat.size === 0, "new file is 0 bytes at start"); + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + yield waitUntilSnapshotState(store, [states.SAVED_CENSUS]); + + let exportEvents = Promise.all([ + waitUntilAction(store, actions.EXPORT_SNAPSHOT_START), + waitUntilAction(store, actions.EXPORT_SNAPSHOT_END) + ]); + dispatch(exportSnapshot(getState().snapshots[0], destPath)); + yield exportEvents; + + stat = yield OS.File.stat(destPath); + do_print(stat.size); + ok(stat.size > 0, "destination file is more than 0 bytes"); + + heapWorker.destroy(); + yield front.detach(); +}); diff --git a/devtools/client/memory/test/unit/test_utils.js b/devtools/client/memory/test/unit/test_utils.js index 7ec94813c6b..519a4327a27 100644 --- a/devtools/client/memory/test/unit/test_utils.js +++ b/devtools/client/memory/test/unit/test_utils.js @@ -35,7 +35,7 @@ add_task(function *() { let s1 = utils.createSnapshot(); let s2 = utils.createSnapshot(); - ok(s1.state, states.SAVING, "utils.createSnapshot() creates snapshot in saving state"); + equal(s1.state, states.SAVING, "utils.createSnapshot() creates snapshot in saving state"); ok(s1.id !== s2.id, "utils.createSnapshot() creates snapshot with unique ids"); ok(utils.breakdownEquals(utils.breakdownNameToSpec("coarseType"), breakdowns.coarseType.breakdown), diff --git a/devtools/client/memory/test/unit/xpcshell.ini b/devtools/client/memory/test/unit/xpcshell.ini index d580f7f3b3d..897db9754b6 100644 --- a/devtools/client/memory/test/unit/xpcshell.ini +++ b/devtools/client/memory/test/unit/xpcshell.ini @@ -5,6 +5,7 @@ tail = firefox-appdir = browser skip-if = toolkit == 'android' || toolkit == 'gonk' +[test_action-export-snapshot.js] [test_action-filter-01.js] [test_action-filter-02.js] [test_action-filter-03.js] diff --git a/devtools/client/memory/utils.js b/devtools/client/memory/utils.js index ac2a0d5d18f..f95e4c38b24 100644 --- a/devtools/client/memory/utils.js +++ b/devtools/client/memory/utils.js @@ -2,7 +2,7 @@ * 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/. */ -const { Cu } = require("chrome"); +const { Cu, Cc, Ci } = require("chrome"); Cu.import("resource://devtools/client/shared/widgets/ViewHelpers.jsm"); const STRINGS_URI = "chrome://devtools/locale/memory.properties" @@ -314,3 +314,40 @@ exports.parseSource = function (source) { return { short, long, host }; }; + +/** + * Takes some configurations and opens up a file picker and returns + * a promise to the chosen file if successful. + * + * @param {String} .title + * The title displayed in the file picker window. + * @param {Array>} .filters + * An array of filters to display in the file picker. Each filter in the array + * is a duple of two strings, one a name for the filter, and one the filter itself + * (like "*.json"). + * @param {String} .defaultName + * The default name chosen by the file picker window. + * @return {Promise} + * The file selected by the user, or null, if cancelled. + */ +exports.openFilePicker = function({ title, filters, defaultName }) { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init(window, title, Ci.nsIFilePicker.modeSave); + + for (let filter of (filters || [])) { + fp.appendFilter(filter[0], filter[1]); + } + fp.defaultString = defaultName; + + return new Promise(resolve => { + fp.open({ + done: result => { + if (result === Ci.nsIFilePicker.returnCancel) { + resolve(null); + return; + } + resolve(fp.file); + } + }); + }); +}; diff --git a/devtools/client/shared/redux/create-store.js b/devtools/client/shared/redux/create-store.js index 22aed294052..cca0f643f07 100644 --- a/devtools/client/shared/redux/create-store.js +++ b/devtools/client/shared/redux/create-store.js @@ -9,14 +9,17 @@ const { waitUntilService } = require("./middleware/wait-service"); const { task } = require("./middleware/task"); const { log } = require("./middleware/log"); const { promise } = require("./middleware/promise"); +const { history } = require("./middleware/history"); /** * This creates a dispatcher with all the standard middleware in place * that all code requires. It can also be optionally configured in * various ways, such as logging and recording. * - * @param {object} opts - boolean configuration flags + * @param {object} opts: * - log: log all dispatched actions to console + * - history: an array to store every action in. Should only be + * used in tests. * - middleware: array of middleware to be included in the redux store */ module.exports = (opts={}) => { @@ -27,13 +30,17 @@ module.exports = (opts={}) => { promise, ]; - if (opts.log) { - middleware.push(log); + if (opts.history) { + middleware.push(history(opts.history)); } if (opts.middleware) { opts.middleware.forEach(fn => middleware.push(fn)); } + if (opts.log) { + middleware.push(log); + } + return applyMiddleware(...middleware)(createStore); } diff --git a/devtools/client/shared/redux/middleware/history.js b/devtools/client/shared/redux/middleware/history.js new file mode 100644 index 00000000000..b76ade01024 --- /dev/null +++ b/devtools/client/shared/redux/middleware/history.js @@ -0,0 +1,23 @@ +/* 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 DevToolsUtils = require("devtools/shared/DevToolsUtils"); + +/** + * A middleware that stores every action coming through the store in the passed + * in logging object. Should only be used for tests, as it collects all + * action information, which will cause memory bloat. + */ +exports.history = (log=[]) => ({ dispatch, getState }) => { + if (!DevToolsUtils.testing) { + console.warn(`Using history middleware stores all actions in state for testing\ + and devtools is not currently running in test mode. Be sure this is\ + intentional.`); + } + return next => action => { + log.push(action); + next(action); + }; +}; diff --git a/devtools/client/shared/redux/middleware/moz.build b/devtools/client/shared/redux/middleware/moz.build index d4024da4058..33fa9f57db5 100644 --- a/devtools/client/shared/redux/middleware/moz.build +++ b/devtools/client/shared/redux/middleware/moz.build @@ -5,6 +5,7 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. DevToolsModules( + 'history.js', 'log.js', 'promise.js', 'task.js', diff --git a/devtools/client/themes/memory.css b/devtools/client/themes/memory.css index 86568f07801..19f0ba6e209 100644 --- a/devtools/client/themes/memory.css +++ b/devtools/client/themes/memory.css @@ -153,7 +153,6 @@ html, body, #app, #memory-tool { color: var(--theme-body-color); border-bottom: 1px solid rgba(128,128,128,0.15); padding: 8px; - cursor: pointer; } .snapshot-list-item.selected { @@ -161,6 +160,17 @@ html, body, #app, #memory-tool { color: var(--theme-selection-color); } +.snapshot-list-item .snapshot-info { + display: flex; + justify-content: space-between; + font-size: 90%; +} + +.snapshot-list-item .save { + text-decoration: underline; + cursor: pointer; +} + .snapshot-list-item > .snapshot-title { margin-bottom: 14px; }