Bug 1215953 - Add feature for importing heap snapshots into the memory

tool. r=fitzgen,ntim
This commit is contained in:
Jordan Santell 2015-11-11 09:52:36 -08:00
parent 225e172a8e
commit 322a1fc861
15 changed files with 221 additions and 19 deletions

View File

@ -32,6 +32,10 @@ snapshot.io.save=Save
# saving a snapshot to disk.
snapshot.io.save.window=Save Heap Snapshot
# LOCALIZATION NOTE (snapshot.io.import.window): The title for the window displayed when
# importing a snapshot form disk.
snapshot.io.import.window=Import Heap Snapshot
# LOCALIZATION NOTE (snapshot.io.filter): The title for the filter used to
# filter file types (*.fxsnapshot)
snapshot.io.filter=Firefox Heap Snapshots
@ -60,6 +64,10 @@ toolbar.breakdownBy=Group by:
# taking a snapshot, either as the main label, or a tooltip.
take-snapshot=Take snapshot
# LOCALIZATION NOTE (import-snapshot): The label describing the button that initiates
# importing a snapshot.
import-snapshot=Import…
# LOCALIZATION NOTE (filter.placeholder): The placeholder text used for the
# memory tool's filter search box.
filter.placeholder=Filter
@ -89,6 +97,11 @@ tree-item.percent=%S%
# state SAVING, used in the main heap view.
snapshot.state.saving.full=Saving snapshot…
# LOCALIZATION NOTE (snapshot.state.importing.full): The label describing the snapshot
# state IMPORTING, used in the main heap view. %S represents the basename of the file
# imported.
snapshot.state.importing.full=Importing %S…
# LOCALIZATION NOTE (snapshot.state.reading.full): The label describing the snapshot
# state READING, and SAVED, due to these states being combined visually, used
# in the main heap view.
@ -106,6 +119,10 @@ snapshot.state.error.full=There was an error processing this snapshot.
# state SAVING, used in the snapshot list view
snapshot.state.saving=Saving snapshot…
# LOCALIZATION NOTE (snapshot.state.importing): The label describing the snapshot
# state IMPORTING, used in the snapshot list view
snapshot.state.importing=Importing snapshot…
# LOCALIZATION NOTE (snapshot.state.reading): The label describing the snapshot
# state READING, and SAVED, due to these states being combined visually, used
# in the snapshot list view.

View File

@ -3,9 +3,10 @@
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { assert } = require("devtools/shared/DevToolsUtils");
const { reportException, assert } = require("devtools/shared/DevToolsUtils");
const { snapshotState: states, actions } = require("../constants");
const { L10N, openFilePicker } = require("../utils");
const { L10N, openFilePicker, createSnapshot } = require("../utils");
const { readSnapshot, takeCensus, selectSnapshot } = require("./snapshot");
const { OS } = require("resource://gre/modules/osfile.jsm");
const VALID_EXPORT_STATES = [states.SAVED, states.READ, states.SAVING_CENSUS, states.SAVED_CENSUS];
@ -14,7 +15,8 @@ exports.pickFileAndExportSnapshot = function (snapshot) {
let outputFile = yield openFilePicker({
title: L10N.getFormatStr("snapshot.io.save.window"),
defaultName: OS.Path.basename(snapshot.path),
filters: [[L10N.getFormatStr("snapshot.io.filter"), "*.fxsnapshot"]]
filters: [[L10N.getFormatStr("snapshot.io.filter"), "*.fxsnapshot"]],
mode: "save",
});
if (!outputFile) {
@ -43,3 +45,43 @@ const exportSnapshot = exports.exportSnapshot = function (snapshot, dest) {
dispatch({ type: actions.EXPORT_SNAPSHOT_END, snapshot });
};
};
const pickFileAndImportSnapshotAndCensus = exports.pickFileAndImportSnapshotAndCensus = function (heapWorker) {
return function* (dispatch, getState) {
let input = yield openFilePicker({
title: L10N.getFormatStr("snapshot.io.import.window"),
filters: [[L10N.getFormatStr("snapshot.io.filter"), "*.fxsnapshot"]],
mode: "open",
});
if (!input) {
return;
}
yield dispatch(importSnapshotAndCensus(heapWorker, input.path));
};
};
const importSnapshotAndCensus = exports.importSnapshotAndCensus = function (heapWorker, path) {
return function* (dispatch, getState) {
let snapshot = createSnapshot();
// Override the defaults for a new snapshot
snapshot.path = path;
snapshot.state = states.IMPORTING;
snapshot.imported = true;
dispatch({ type: actions.IMPORT_SNAPSHOT_START, snapshot });
dispatch(selectSnapshot(snapshot));
try {
yield dispatch(readSnapshot(heapWorker, snapshot));
yield dispatch(takeCensus(heapWorker, snapshot));
} catch (error) {
reportException("importSnapshot", error);
dispatch({ type: actions.IMPORT_SNAPSHOT_ERROR, error, snapshot });
}
dispatch({ type: actions.IMPORT_SNAPSHOT_END, snapshot });
};
};

View File

@ -72,7 +72,7 @@ const takeSnapshot = exports.takeSnapshot = function (front) {
*/
const readSnapshot = exports.readSnapshot = function readSnapshot (heapWorker, snapshot) {
return function *(dispatch, getState) {
assert(snapshot.state === states.SAVED,
assert([states.SAVED, states.IMPORTING].includes(snapshot.state),
`Should only read a snapshot once. Found snapshot in state ${snapshot.state}`);
let creationTime;

View File

@ -9,7 +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 { pickFileAndExportSnapshot, pickFileAndImportSnapshotAndCensus } = require("./actions/io");
const { selectSnapshotAndRefresh, takeSnapshotAndCensus } = require("./actions/snapshot");
const { breakdownNameToSpec, getBreakdownDisplayData } = require("./utils");
const Toolbar = createFactory(require("./components/toolbar"));
@ -61,6 +61,7 @@ const App = createClass({
Toolbar({
breakdowns: getBreakdownDisplayData(),
onImportClick: () => dispatch(pickFileAndImportSnapshotAndCensus(heapWorker)),
onTakeSnapshotClick: () => dispatch(takeSnapshotAndCensus(front, heapWorker)),
onBreakdownChange: breakdown =>
dispatch(setBreakdownAndRefresh(heapWorker, breakdownNameToSpec(breakdown))),

View File

@ -113,6 +113,7 @@ const Heap = module.exports = createClass({
dom.pre({}, safeErrorString(snapshot.error))
];
break;
case states.IMPORTING:
case states.SAVING:
case states.SAVED:
case states.READING:

View File

@ -14,6 +14,7 @@ const Toolbar = module.exports = createClass({
displayName: PropTypes.string.isRequired,
})).isRequired,
onTakeSnapshotClick: PropTypes.func.isRequired,
onImportClick: PropTypes.func.isRequired,
onBreakdownChange: PropTypes.func.isRequired,
onToggleRecordAllocationStacks: PropTypes.func.isRequired,
allocations: models.allocations,
@ -26,6 +27,7 @@ const Toolbar = module.exports = createClass({
render() {
let {
onTakeSnapshotClick,
onImportClick,
onBreakdownChange,
breakdowns,
onToggleRecordAllocationStacks,
@ -45,6 +47,14 @@ const Toolbar = module.exports = createClass({
title: L10N.getStr("take-snapshot")
}),
dom.button({
id: "import-snapshot",
className: "devtools-toolbarbutton import-snapshot devtools-button",
onClick: onImportClick,
title: L10N.getStr("import-snapshot"),
"data-text-only": true,
}, L10N.getStr("import-snapshot")),
dom.div({ className: "toolbar-group" },
dom.label({ className: "breakdown-by" },
L10N.getStr("toolbar.breakdownBy"),

View File

@ -29,6 +29,12 @@ actions.EXPORT_SNAPSHOT_START = "export-snapshot-start";
actions.EXPORT_SNAPSHOT_END = "export-snapshot-end";
actions.EXPORT_SNAPSHOT_ERROR = "export-snapshot-error";
// When a heap snapshot is being read from a user selected file,
// and represents the entire state until the census is available.
actions.IMPORT_SNAPSHOT_START = "import-snapshot-start";
actions.IMPORT_SNAPSHOT_END = "import-snapshot-end";
actions.IMPORT_SNAPSHOT_ERROR = "import-snapshot-error";
// Fired by UI to select a snapshot to view.
actions.SELECT_SNAPSHOT = "select-snapshot";
@ -93,14 +99,15 @@ const snapshotState = exports.snapshotState = {};
* Various states a snapshot can be in.
* An FSM describing snapshot states:
*
* SAVING -> SAVED -> READING -> READ <- <- <- SAVED_CENSUS
*
* SAVING_CENSUS
* SAVING -> SAVED -> READING -> READ SAVED_CENSUS
* IMPORTING
* SAVING_CENSUS
*
* Any of these states may go to the ERROR state, from which they can never
* leave (mwah ha ha ha!)
*/
snapshotState.ERROR = "snapshot-state-error";
snapshotState.IMPORTING = "snapshot-state-importing";
snapshotState.SAVING = "snapshot-state-saving";
snapshotState.SAVED = "snapshot-state-saved";
snapshotState.READING = "snapshot-state-reading";

View File

@ -39,13 +39,15 @@ let snapshotModel = exports.snapshot = PropTypes.shape({
filter: PropTypes.string,
// If an error was thrown while processing this snapshot, the `Error` instance is attached here.
error: PropTypes.object,
// Boolean indicating whether or not this snapshot was imported.
imported: PropTypes.bool.isRequired,
// The creation time of the snapshot; required after the snapshot has been read.
creationTime: PropTypes.number,
// The current state the snapshot is in.
// @see ./constants.js
state: function (snapshot, propName) {
let current = snapshot.state;
let shouldHavePath = [states.SAVED, states.READ, states.SAVING_CENSUS, states.SAVED_CENSUS];
let shouldHavePath = [states.IMPORTING, states.SAVED, states.READ, states.SAVING_CENSUS, states.SAVED_CENSUS];
let shouldHaveCreationTime = [states.READ, states.SAVING_CENSUS, states.SAVED_CENSUS];
let shouldHaveCensus = [states.SAVED_CENSUS];

View File

@ -28,6 +28,8 @@ handlers[actions.TAKE_SNAPSHOT_END] = function (snapshots, action) {
});
};
handlers[actions.IMPORT_SNAPSHOT_START] = handlers[actions.TAKE_SNAPSHOT_START];
handlers[actions.READ_SNAPSHOT_START] = function (snapshots, action) {
let snapshot = getSnapshot(snapshots, action.snapshot);
snapshot.state = states.READING;

View File

@ -119,3 +119,12 @@ function isBreakdownType (census, type) {
throw new Error(`isBreakdownType does not yet support ${type}`);
}
}
function *createTempFile () {
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");
return destPath;
}

View File

@ -18,12 +18,7 @@ add_task(function *() {
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");
let destPath = yield createTempFile();
dispatch(takeSnapshotAndCensus(front, heapWorker));
yield waitUntilSnapshotState(store, [states.SAVED_CENSUS]);

View File

@ -0,0 +1,85 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/**
* Tests the task creator `importSnapshotAndCensus()` for the whole flow of
* importing a snapshot, and its sub-actions.
*/
let { actions, snapshotState: states } = require("devtools/client/memory/constants");
let { breakdownEquals } = require("devtools/client/memory/utils");
let { exportSnapshot, importSnapshotAndCensus } = require("devtools/client/memory/actions/io");
let { takeSnapshotAndCensus } = require("devtools/client/memory/actions/snapshot");
function run_test() {
run_next_test();
}
add_task(function *() {
let front = new StubbedMemoryFront();
let heapWorker = new HeapAnalysesClient();
yield front.attach();
let store = Store();
let { subscribe, dispatch, getState } = store;
let destPath = yield createTempFile();
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;
// Now import our freshly exported snapshot
let i = 0;
let expected = ["IMPORTING", "READING", "READ", "SAVING_CENSUS", "SAVED_CENSUS"];
let expectStates = () => {
let snapshot = getState().snapshots[1];
if (!snapshot) {
return;
}
let isCorrectState = snapshot.state === states[expected[i]];
if (isCorrectState) {
ok(true, `Found expected state ${expected[i]}`);
i++;
}
};
let unsubscribe = subscribe(expectStates);
dispatch(importSnapshotAndCensus(heapWorker, destPath));
yield waitUntilState(store, () => i === 5);
unsubscribe();
equal(i, 5, "importSnapshotAndCensus() produces the correct sequence of states in a snapshot");
equal(getState().snapshots[1].state, states.SAVED_CENSUS, "imported snapshot is in SAVED_CENSUS state");
ok(getState().snapshots[1].selected, "imported snapshot is selected");
// Check snapshot data
let snapshot1 = getState().snapshots[0];
let snapshot2 = getState().snapshots[1];
ok(breakdownEquals(snapshot1.breakdown, snapshot2.breakdown),
"imported snapshot has correct breakdown");
// Clone the census data so we can destructively remove the ID/parents to compare
// equal census data
let census1 = stripUnique(JSON.parse(JSON.stringify(snapshot1.census)));
let census2 = stripUnique(JSON.parse(JSON.stringify(snapshot2.census)));
equal(JSON.stringify(census1), JSON.stringify(census2), "Imported snapshot has correct census");
function stripUnique (obj) {
let children = obj.children || [];
for (let child of children) {
delete child.id;
delete child.parent;
stripUnique(child);
}
delete obj.id;
delete obj.parent;
return obj;
}
});

View File

@ -9,6 +9,7 @@ skip-if = toolkit == 'android' || toolkit == 'gonk'
[test_action-filter-01.js]
[test_action-filter-02.js]
[test_action-filter-03.js]
[test_action-import-snapshot-and-census.js]
[test_action-select-snapshot.js]
[test_action-set-breakdown.js]
[test_action-set-breakdown-and-refresh-01.js]

View File

@ -9,6 +9,7 @@ const STRINGS_URI = "chrome://devtools/locale/memory.properties"
const L10N = exports.L10N = new ViewHelpers.L10N(STRINGS_URI);
const { URL } = require("sdk/url");
const { OS } = require("resource://gre/modules/osfile.jsm");
const { assert } = require("devtools/shared/DevToolsUtils");
const { Preferences } = require("resource://gre/modules/Preferences.jsm");
const CUSTOM_BREAKDOWN_PREF = "devtools.memory.custom-breakdowns";
@ -27,6 +28,11 @@ exports.getSnapshotTitle = function (snapshot) {
return L10N.getStr("snapshot-title.loading");
}
if (snapshot.imported) {
// Strip out the extension if it's the expected ".fxsnapshot"
return OS.Path.basename(snapshot.path.replace(/\.fxsnapshot$/, ""));
}
let date = new Date(snapshot.creationTime / 1000);
return date.toLocaleTimeString(void 0, {
year: "2-digit",
@ -124,6 +130,8 @@ exports.getSnapshotStatusText = function (snapshot) {
return L10N.getStr("snapshot.state.error");
case states.SAVING:
return L10N.getStr("snapshot.state.saving");
case states.IMPORTING:
return L10N.getStr("snapshot.state.importing");
case states.SAVED:
case states.READING:
return L10N.getStr("snapshot.state.reading");
@ -155,6 +163,8 @@ exports.getSnapshotStatusTextFull = function (snapshot) {
return L10N.getStr("snapshot.state.error.full");
case states.SAVING:
return L10N.getStr("snapshot.state.saving.full");
case states.IMPORTING:
return L10N.getFormatStr("snapshot.state.importing", OS.Path.basename(snapshot.path));
case states.SAVED:
case states.READING:
return L10N.getStr("snapshot.state.reading.full");
@ -198,6 +208,8 @@ exports.createSnapshot = function createSnapshot () {
state: states.SAVING,
census: null,
path: null,
imported: false,
selected: false,
};
};
@ -327,12 +339,21 @@ exports.parseSource = function (source) {
* (like "*.json").
* @param {String} .defaultName
* The default name chosen by the file picker window.
* @param {String} .mode
* The mode that this filepicker should open in. Can be "open" or "save".
* @return {Promise<?nsILocalFile>}
* The file selected by the user, or null, if cancelled.
*/
exports.openFilePicker = function({ title, filters, defaultName }) {
exports.openFilePicker = function({ title, filters, defaultName, mode }) {
mode = mode === "save" ? Ci.nsIFilePicker.modeSave :
mode === "open" ? Ci.nsIFilePicker.modeOpen : null;
if (mode == void 0) {
throw new Error("No valid mode specified for nsIFilePicker.");
}
let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
fp.init(window, title, Ci.nsIFilePicker.modeSave);
fp.init(window, title, mode);
for (let filter of (filters || [])) {
fp.appendFilter(filter[0], filter[1]);

View File

@ -64,9 +64,10 @@ html, body, #app, #memory-tool {
/**
* We want this to be exactly at a --sidebar-width distance from the
* toolbar's start boundary. A .devtools-toolbar has a 3px start padding
* and the preceeding .take-snapshot button is exactly 32px.
* and the preceeding .take-snapshot button is exactly 32px, and the import
* button 78px.
*/
margin-inline-start: calc(var(--sidebar-width) - 3px - 32px);
margin-inline-start: calc(var(--sidebar-width) - 3px - 32px - 78px);
border-inline-start: 1px solid var(--theme-splitter-color);
padding-inline-start: 5px;
}
@ -96,6 +97,14 @@ html, body, #app, #memory-tool {
}
}
/**
* Due to toolbar styles of `.devtools-toolbarbutton:not([label])` which overrides
* .devtools-toolbarbutton's min-width of 78px, reset the min-width.
*/
#import-snapshot {
min-width: 78px;
}
.spacer {
flex: 1;
}