Bug 1215954 - Add feature to save a heap snapshot from memory tool to

disk. r=fitzgen,vp
This commit is contained in:
Jordan Santell 2015-11-05 17:06:14 -08:00
parent aa32ce7f03
commit cb4eb14f47
17 changed files with 246 additions and 14 deletions

View File

@ -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

View File

@ -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 });
};
};

View File

@ -8,5 +8,6 @@ DevToolsModules(
'breakdown.js',
'filter.js',
'inverted.js',
'io.js',
'snapshot.js',
)

View File

@ -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({

View File

@ -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),
});
}));
}))
);
}

View File

@ -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
)
)
);
}

View File

@ -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";

View File

@ -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;
};

View File

@ -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;

View File

@ -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();
});

View File

@ -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),

View File

@ -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]

View File

@ -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<Array<String>>} .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<?nsILocalFile>}
* 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);
}
});
});
};

View File

@ -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);
}

View File

@ -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);
};
};

View File

@ -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',

View File

@ -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;
}