summaryrefslogtreecommitdiffstats
path: root/devtools/client/performance/modules/logic/frame-utils.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/performance/modules/logic/frame-utils.js')
-rw-r--r--devtools/client/performance/modules/logic/frame-utils.js510
1 files changed, 510 insertions, 0 deletions
diff --git a/devtools/client/performance/modules/logic/frame-utils.js b/devtools/client/performance/modules/logic/frame-utils.js
new file mode 100644
index 0000000000..a799381274
--- /dev/null
+++ b/devtools/client/performance/modules/logic/frame-utils.js
@@ -0,0 +1,510 @@
+/* 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 global = require("devtools/client/performance/modules/global");
+const demangle = require("devtools/client/shared/demangle");
+const { assert } = require("devtools/shared/DevToolsUtils");
+const {
+ isChromeScheme,
+ isContentScheme,
+ isWASM,
+ parseURL,
+} = require("devtools/client/shared/source-utils");
+
+const {
+ CATEGORY_INDEX,
+ CATEGORIES,
+} = require("devtools/client/performance/modules/categories");
+
+// Character codes used in various parsing helper functions.
+const CHAR_CODE_R = "r".charCodeAt(0);
+const CHAR_CODE_0 = "0".charCodeAt(0);
+const CHAR_CODE_9 = "9".charCodeAt(0);
+const CHAR_CODE_CAP_Z = "Z".charCodeAt(0);
+
+const CHAR_CODE_LPAREN = "(".charCodeAt(0);
+const CHAR_CODE_RPAREN = ")".charCodeAt(0);
+const CHAR_CODE_COLON = ":".charCodeAt(0);
+const CHAR_CODE_SPACE = " ".charCodeAt(0);
+const CHAR_CODE_UNDERSCORE = "_".charCodeAt(0);
+
+const EVAL_TOKEN = "%20%3E%20eval";
+
+// The cache used to store inflated frames.
+const gInflatedFrameStore = new WeakMap();
+
+// The cache used to store frame data from `getInfo`.
+const gFrameData = new WeakMap();
+
+/**
+ * Parses the raw location of this function call to retrieve the actual
+ * function name, source url, host name, line and column.
+ */
+// eslint-disable-next-line complexity
+function parseLocation(location, fallbackLine, fallbackColumn) {
+ // Parse the `location` for the function name, source url, line, column etc.
+
+ let line, column, url;
+
+ // These two indices are used to extract the resource substring, which is
+ // location[parenIndex + 1 .. lineAndColumnIndex].
+ //
+ // There are 3 variants of location strings in the profiler (with optional
+ // column numbers):
+ // 1) "name (resource:line)"
+ // 2) "resource:line"
+ // 3) "resource"
+ //
+ // For example for (1), take "foo (bar.js:1)".
+ // ^ ^
+ // | |
+ // | |
+ // | |
+ // parenIndex will point to ------+ |
+ // |
+ // lineAndColumnIndex will point to -----+
+ //
+ // For an example without parentheses, take "bar.js:2".
+ // ^ ^
+ // | |
+ // parenIndex will point to ----------------+ |
+ // |
+ // lineAndColumIndex will point to ----------------+
+ //
+ // To parse, we look for the last occurrence of the string ' ('.
+ //
+ // For 1), all occurrences of space ' ' characters in the resource string
+ // are urlencoded, so the last occurrence of ' (' is the separator between
+ // the function name and the resource.
+ //
+ // For 2) and 3), there can be no occurences of ' (' since ' ' characters
+ // are urlencoded in the resource string.
+ //
+ // XXX: Note that 3) is ambiguous with Gecko Profiler marker locations like
+ // "EnterJIT". We can't distinguish the two, so we treat 3) like a function
+ // name.
+ let parenIndex = -1;
+ let lineAndColumnIndex = -1;
+
+ const lastCharCode = location.charCodeAt(location.length - 1);
+ let i;
+ if (lastCharCode === CHAR_CODE_RPAREN) {
+ // Case 1)
+ i = location.length - 2;
+ } else if (isNumeric(lastCharCode)) {
+ // Case 2)
+ i = location.length - 1;
+ } else {
+ // Case 3)
+ i = 0;
+ }
+
+ if (i !== 0) {
+ // Look for a :number.
+ let end = i;
+ while (isNumeric(location.charCodeAt(i))) {
+ i--;
+ }
+ if (location.charCodeAt(i) === CHAR_CODE_COLON) {
+ column = location.substr(i + 1, end - i);
+ i--;
+ }
+
+ // Look for a preceding :number.
+ end = i;
+ while (isNumeric(location.charCodeAt(i))) {
+ i--;
+ }
+
+ // If two were found, the first is the line and the second is the
+ // column. If only a single :number was found, then it is the line number.
+ if (location.charCodeAt(i) === CHAR_CODE_COLON) {
+ line = location.substr(i + 1, end - i);
+ lineAndColumnIndex = i;
+ i--;
+ } else {
+ lineAndColumnIndex = i + 1;
+ line = column;
+ column = undefined;
+ }
+ }
+
+ // Look for the last occurrence of ' (' in case 1).
+ if (lastCharCode === CHAR_CODE_RPAREN) {
+ for (; i >= 0; i--) {
+ if (
+ location.charCodeAt(i) === CHAR_CODE_LPAREN &&
+ i > 0 &&
+ location.charCodeAt(i - 1) === CHAR_CODE_SPACE
+ ) {
+ parenIndex = i;
+ break;
+ }
+ }
+ }
+
+ let parsedUrl;
+ if (lineAndColumnIndex > 0) {
+ const resource = location.substring(parenIndex + 1, lineAndColumnIndex);
+ url = resource.split(" -> ").pop();
+ if (url) {
+ parsedUrl = parseURL(url);
+ }
+ }
+
+ let functionName, fileName, port, host;
+ line = line || fallbackLine;
+ column = column || fallbackColumn;
+
+ // If the URL digged out from the `location` is valid, this is a JS frame.
+ if (parsedUrl) {
+ functionName = location.substring(0, parenIndex - 1);
+ fileName = parsedUrl.fileName;
+ port = parsedUrl.port;
+ host = parsedUrl.host;
+
+ // Check for the case of the filename containing eval
+ // e.g. "file.js%20line%2065%20%3E%20eval"
+ const evalIndex = fileName.indexOf(EVAL_TOKEN);
+ if (evalIndex !== -1 && evalIndex === fileName.length - EVAL_TOKEN.length) {
+ // Match the filename
+ const evalLine = line;
+ const [, _fileName, , _line] =
+ fileName.match(/(.+)(%20line%20(\d+)%20%3E%20eval)/) || [];
+ fileName = `${_fileName} (eval:${evalLine})`;
+ line = _line;
+ assert(
+ _fileName !== undefined,
+ "Filename could not be found from an eval location site"
+ );
+ assert(
+ _line !== undefined,
+ "Line could not be found from an eval location site"
+ );
+
+ // Match the url as well
+ [, url] = url.match(/(.+)( line (\d+) > eval)/) || [];
+ assert(
+ url !== undefined,
+ "The URL could not be parsed correctly from an eval location site"
+ );
+ }
+ } else {
+ functionName = location;
+ url = null;
+ }
+
+ return { functionName, fileName, host, port, url, line, column };
+}
+
+/**
+ * Sets the properties of `isContent` and `category` on a frame.
+ *
+ * @param {InflatedFrame} frame
+ */
+function computeIsContentAndCategory(frame) {
+ const location = frame.location;
+
+ // There are 3 variants of location strings in the profiler (with optional
+ // column numbers):
+ // 1) "name (resource:line)"
+ // 2) "resource:line"
+ // 3) "resource"
+ const lastCharCode = location.charCodeAt(location.length - 1);
+ let schemeStartIndex = -1;
+ if (lastCharCode === CHAR_CODE_RPAREN) {
+ // Case 1)
+ //
+ // Need to search for the last occurrence of ' (' to find the start of the
+ // resource string.
+ for (let i = location.length - 2; i >= 0; i--) {
+ if (
+ location.charCodeAt(i) === CHAR_CODE_LPAREN &&
+ i > 0 &&
+ location.charCodeAt(i - 1) === CHAR_CODE_SPACE
+ ) {
+ schemeStartIndex = i + 1;
+ break;
+ }
+ }
+ } else {
+ // Cases 2) and 3)
+ schemeStartIndex = 0;
+ }
+
+ // We can't know if WASM frames are content or not at the time of this writing, so label
+ // them all as content.
+ if (isContentScheme(location, schemeStartIndex) || isWASM(location)) {
+ frame.isContent = true;
+ return;
+ }
+
+ if (frame.category !== null && frame.category !== undefined) {
+ return;
+ }
+
+ if (schemeStartIndex !== 0) {
+ for (let j = schemeStartIndex; j < location.length; j++) {
+ if (
+ location.charCodeAt(j) === CHAR_CODE_R &&
+ isChromeScheme(location, j) &&
+ (location.includes("resource://devtools") ||
+ location.includes("resource://devtools"))
+ ) {
+ frame.category = CATEGORY_INDEX("tools");
+ return;
+ }
+ }
+ }
+
+ if (location === "EnterJIT") {
+ frame.category = CATEGORY_INDEX("js");
+ return;
+ }
+
+ frame.category = CATEGORY_INDEX("other");
+}
+
+/**
+ * Get caches to cache inflated frames and computed frame keys of a frame
+ * table.
+ *
+ * @param object framesTable
+ * @return object
+ */
+function getInflatedFrameCache(frameTable) {
+ let inflatedCache = gInflatedFrameStore.get(frameTable);
+ if (inflatedCache !== undefined) {
+ return inflatedCache;
+ }
+
+ // Fill with nulls to ensure no holes.
+ inflatedCache = Array.from({ length: frameTable.data.length }, () => null);
+ gInflatedFrameStore.set(frameTable, inflatedCache);
+ return inflatedCache;
+}
+
+/**
+ * Get or add an inflated frame to a cache.
+ *
+ * @param object cache
+ * @param number index
+ * @param object frameTable
+ * @param object stringTable
+ */
+function getOrAddInflatedFrame(cache, index, frameTable, stringTable) {
+ let inflatedFrame = cache[index];
+ if (inflatedFrame === null) {
+ inflatedFrame = cache[index] = new InflatedFrame(
+ index,
+ frameTable,
+ stringTable
+ );
+ }
+ return inflatedFrame;
+}
+
+/**
+ * An intermediate data structured used to hold inflated frames.
+ *
+ * @param number index
+ * @param object frameTable
+ * @param object stringTable
+ */
+function InflatedFrame(index, frameTable, stringTable) {
+ const LOCATION_SLOT = frameTable.schema.location;
+ const IMPLEMENTATION_SLOT = frameTable.schema.implementation;
+ const OPTIMIZATIONS_SLOT = frameTable.schema.optimizations;
+ const LINE_SLOT = frameTable.schema.line;
+ const CATEGORY_SLOT = frameTable.schema.category;
+
+ const frame = frameTable.data[index];
+ const category = frame[CATEGORY_SLOT];
+ this.location = stringTable[frame[LOCATION_SLOT]];
+ this.implementation = frame[IMPLEMENTATION_SLOT];
+ this.optimizations = frame[OPTIMIZATIONS_SLOT];
+ this.line = frame[LINE_SLOT];
+ this.column = undefined;
+ this.category = category;
+ this.isContent = false;
+
+ // Attempt to compute if this frame is a content frame, and if not,
+ // its category.
+ //
+ // Since only C++ stack frames have associated category information,
+ // attempt to generate a useful category, fallback to the one provided
+ // by the profiling data, or fallback to an unknown category.
+ computeIsContentAndCategory(this);
+}
+
+/**
+ * Gets the frame key (i.e., equivalence group) according to options. Content
+ * frames are always identified by location. Chrome frames are identified by
+ * location if content-only filtering is off. If content-filtering is on, they
+ * are identified by their category.
+ *
+ * @param object options
+ * @return string
+ */
+InflatedFrame.prototype.getFrameKey = function getFrameKey(options) {
+ if (this.isContent || !options.contentOnly || options.isRoot) {
+ options.isMetaCategoryOut = false;
+ return this.location;
+ }
+
+ if (options.isLeaf) {
+ // We only care about leaf platform frames if we are displaying content
+ // only. If no category is present, give the default category of "other".
+ //
+ // 1. The leaf is where time is _actually_ being spent, so we _need_ to
+ // show it to developers in some way to give them accurate profiling
+ // data. We decide to split the platform into various category buckets
+ // and just show time spent in each bucket.
+ //
+ // 2. The calls leading to the leaf _aren't_ where we are spending time,
+ // but _do_ give the developer context for how they got to the leaf
+ // where they _are_ spending time. For non-platform hackers, the
+ // non-leaf platform frames don't give any meaningful context, and so we
+ // can safely filter them out.
+ options.isMetaCategoryOut = true;
+ return this.category;
+ }
+
+ // Return an empty string denoting that this frame should be skipped.
+ return "";
+};
+
+function isNumeric(c) {
+ return c >= CHAR_CODE_0 && c <= CHAR_CODE_9;
+}
+
+function shouldDemangle(name) {
+ return (
+ name?.charCodeAt &&
+ name.charCodeAt(0) === CHAR_CODE_UNDERSCORE &&
+ name.charCodeAt(1) === CHAR_CODE_UNDERSCORE &&
+ name.charCodeAt(2) === CHAR_CODE_CAP_Z
+ );
+}
+
+/**
+ * Calculates the relative costs of this frame compared to a root,
+ * and generates allocations information if specified. Uses caching
+ * if possible.
+ *
+ * @param {ThreadNode|FrameNode} node
+ * The node we are calculating.
+ * @param {ThreadNode} options.root
+ * The root thread node to calculate relative costs.
+ * Generates [self|total] [duration|percentage] values.
+ * @param {boolean} options.allocations
+ * Generates `totalAllocations` and `selfAllocations`.
+ *
+ * @return {object}
+ */
+function getFrameInfo(node, options) {
+ let data = gFrameData.get(node);
+
+ if (!data) {
+ if (node.nodeType === "Thread") {
+ data = Object.create(null);
+ data.functionName = global.L10N.getStr("table.root");
+ } else {
+ data = parseLocation(node.location, node.line, node.column);
+ data.hasOptimizations = node.hasOptimizations();
+ data.isContent = node.isContent;
+ data.isMetaCategory = node.isMetaCategory;
+ }
+ data.samples = node.youngestFrameSamples;
+ const hasCategory = node.category !== null && node.category !== undefined;
+ data.categoryData = hasCategory
+ ? CATEGORIES[node.category] || CATEGORIES[CATEGORY_INDEX("other")]
+ : {};
+ data.nodeType = node.nodeType;
+
+ // Frame name (function location or some meta information)
+ if (data.isMetaCategory) {
+ data.name = data.categoryData.label;
+ } else if (shouldDemangle(data.functionName)) {
+ data.name = demangle(data.functionName);
+ } else {
+ data.name = data.functionName;
+ }
+
+ data.tooltiptext = data.isMetaCategory
+ ? data.categoryData.label
+ : node.location || "";
+
+ gFrameData.set(node, data);
+ }
+
+ // If no options specified, we can't calculate relative values, abort here
+ if (!options) {
+ return data;
+ }
+
+ // If a root specified, calculate the relative costs in the context of
+ // this call tree. The cached store may already have this, but generate
+ // if it does not.
+ const totalSamples = options.root.samples;
+ const totalDuration = options.root.duration;
+ if (options?.root && !data.COSTS_CALCULATED) {
+ data.selfDuration =
+ (node.youngestFrameSamples / totalSamples) * totalDuration;
+ data.selfPercentage = (node.youngestFrameSamples / totalSamples) * 100;
+ data.totalDuration = (node.samples / totalSamples) * totalDuration;
+ data.totalPercentage = (node.samples / totalSamples) * 100;
+ data.COSTS_CALCULATED = true;
+ }
+
+ if (options?.allocations && !data.ALLOCATION_DATA_CALCULATED) {
+ const totalBytes = options.root.byteSize;
+ data.selfCount = node.youngestFrameSamples;
+ data.totalCount = node.samples;
+ data.selfCountPercentage = (node.youngestFrameSamples / totalSamples) * 100;
+ data.totalCountPercentage = (node.samples / totalSamples) * 100;
+ data.selfSize = node.youngestFrameByteSize;
+ data.totalSize = node.byteSize;
+ data.selfSizePercentage = (node.youngestFrameByteSize / totalBytes) * 100;
+ data.totalSizePercentage = (node.byteSize / totalBytes) * 100;
+ data.ALLOCATION_DATA_CALCULATED = true;
+ }
+
+ return data;
+}
+
+exports.getFrameInfo = getFrameInfo;
+
+/**
+ * Takes an inverted ThreadNode and searches its youngest frames for
+ * a FrameNode with matching location.
+ *
+ * @param {ThreadNode} threadNode
+ * @param {string} location
+ * @return {?FrameNode}
+ */
+function findFrameByLocation(threadNode, location) {
+ if (!threadNode.inverted) {
+ throw new Error(
+ "FrameUtils.findFrameByLocation only supports leaf nodes in an inverted tree."
+ );
+ }
+
+ const calls = threadNode.calls;
+ for (let i = 0; i < calls.length; i++) {
+ if (calls[i].location === location) {
+ return calls[i];
+ }
+ }
+ return null;
+}
+
+exports.findFrameByLocation = findFrameByLocation;
+exports.computeIsContentAndCategory = computeIsContentAndCategory;
+exports.parseLocation = parseLocation;
+exports.getInflatedFrameCache = getInflatedFrameCache;
+exports.getOrAddInflatedFrame = getOrAddInflatedFrame;
+exports.InflatedFrame = InflatedFrame;
+exports.shouldDemangle = shouldDemangle;