From 11f6ba3c1c738bea6b72327bd0b8c12a19ebc1a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 10 Apr 2026 12:38:55 +0200 Subject: [PATCH 01/63] Add "drop samples with category" transform This is useful for cases where I want to drop all the "idle" category samples, so I can focus on non-idle samples. Or it can be useful for dropping the whole profiler overhead by removing the "profiler" category. I would like to use this transform in the pq cli tool, so we can remove the idle category before finding the "top functions". Otherwise the idle category just pollutes the output of the cli a lot. --- locales/en-US/app.ftl | 14 ++ src/actions/profile-view.ts | 8 ++ src/app-logic/url-handling.ts | 6 +- src/components/shared/CallNodeContextMenu.tsx | 23 ++++ src/profile-logic/transforms.ts | 70 +++++++++- .../components/CallNodeContextMenu.test.tsx | 5 +- .../components/TransformShortcuts.test.tsx | 9 ++ .../CallNodeContextMenu.test.tsx.snap | 24 ++++ src/test/store/transforms.test.ts | 122 ++++++++++++++++++ src/test/url-handling.test.ts | 6 +- src/types/transforms.ts | 26 ++++ src/utils/types.ts | 1 + 12 files changed, 309 insertions(+), 5 deletions(-) diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 9475a20d4c..89e8893018 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -125,6 +125,15 @@ CallNodeContextMenu--transform-focus-category = Focus on category { $cat .title = Focusing on the nodes that belong to the same category as the selected node, thereby merging all nodes that belong to another category. + +# This is used as the context menu item to apply the "Drop category" transform. +# Variables: +# $categoryName (String) - Name of the category to drop. +CallNodeContextMenu--transform-drop-category = Drop samples with category { $categoryName } + .title = + Dropping samples with a category removes all samples whose leaf frame + belongs to that category. + CallNodeContextMenu--transform-collapse-function-subtree = Collapse function .title = Collapsing a function will remove everything it called, and assign @@ -1180,6 +1189,11 @@ TransformNavigator--focus-self = Focus Self: { $item } # $item (String) - Name of the category that transform applied to. TransformNavigator--focus-category = Focus category: { $item } +# "Drop samples with category" transform (drop is a verb, as in remove). +# Variables: +# $item (String) - Name of the category that transform applied to. +TransformNavigator--drop-category = Drop samples with category: { $item } + # "Merge call node" transform. # See: https://profiler.firefox.com/docs/#/./guide-filtering-call-trees?id=merge # Variables: diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index a7bf7ddc39..34bdfc7fab 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -2103,6 +2103,14 @@ export function handleCallNodeTransformShortcut( }) ); break; + case 'G': + dispatch( + addTransformToStack(threadsKey, { + type: 'drop-category', + category, + }) + ); + break; default: // This did not match a call tree transform. } diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index eecd06a2be..2430c5c2db 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -53,7 +53,7 @@ import { StringTable } from 'firefox-profiler/utils/string-table'; import type { ProfileUpgradeInfo } from 'firefox-profiler/profile-logic/processed-profile-versioning'; import type { ProfileAndProfileUpgradeInfo } from 'firefox-profiler/actions/receive-profile'; -export const CURRENT_URL_VERSION = 15; +export const CURRENT_URL_VERSION = 16; /** * This static piece of state might look like an anti-pattern, but it's a relatively @@ -1351,6 +1351,10 @@ const _upgraders: { .map(mapIndexesInTransform) .join('~'); }, + [16]: (_) => { + // Added the 'drop-category' transform (short key 'dg'). No existing URLs + // need rewriting; this is a new transform with no prior encoding to migrate. + }, }; for (let destVersion = 1; destVersion <= CURRENT_URL_VERSION; destVersion++) { diff --git a/src/components/shared/CallNodeContextMenu.tsx b/src/components/shared/CallNodeContextMenu.tsx index 1a360f01c6..561412df8b 100644 --- a/src/components/shared/CallNodeContextMenu.tsx +++ b/src/components/shared/CallNodeContextMenu.tsx @@ -422,6 +422,13 @@ class CallNodeContextMenuImpl extends React.PureComponent { }); break; } + case 'drop-category': { + addTransformToStack(threadsKey, { + type: 'drop-category', + category, + }); + break; + } case 'filter-samples': throw new Error( "Filter samples transform can't be applied from the call node context menu." @@ -714,6 +721,22 @@ class CallNodeContextMenuImpl extends React.PureComponent { }) : null} + {hasCategory + ? this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-drop-category', + l10nProps: { + vars: { categoryName }, + elems: { strong: }, + }, + shortcut: 'G', + icon: 'Drop', + onClick: this._handleClick, + transform: 'drop-category', + title: '', + content: 'Drop samples with this category', + }) + : null} + {this.renderTransformMenuItem({ l10nId: 'CallNodeContextMenu--transform-collapse-function-subtree', shortcut: 'c', diff --git a/src/profile-logic/transforms.ts b/src/profile-logic/transforms.ts index 05ba86b2bc..783a5420b5 100644 --- a/src/profile-logic/transforms.ts +++ b/src/profile-logic/transforms.ts @@ -74,6 +74,7 @@ const TRANSFORM_OBJ: { [key in TransformType]: true } = { 'collapse-recursion': true, 'collapse-function-subtree': true, 'focus-category': true, + 'drop-category': true, 'filter-samples': true, }; export const ALL_TRANSFORM_TYPES: TransformType[] = Object.keys( @@ -100,6 +101,9 @@ ALL_TRANSFORM_TYPES.forEach((transform: TransformType) => { case 'focus-category': shortKey = 'fg'; break; + case 'drop-category': + shortKey = 'dg'; + break; case 'merge-call-node': shortKey = 'mcn'; break; @@ -277,6 +281,19 @@ export function parseTransforms(transformString: string): TransformStack { } break; } + case 'drop-category': { + // e.g. "dg-3" + const [, categoryRaw] = tuple; + const category = parseInt(categoryRaw, 10); + // Validate that the category makes sense. + if (!isNaN(category) && category >= 0) { + transforms.push({ + type: 'drop-category', + category, + }); + } + break; + } case 'focus-subtree': case 'merge-call-node': { // e.g. "f-js-xFFpUMl-i" or "f-cpp-0KV4KV5KV61KV7KV8K" @@ -378,6 +395,7 @@ export function stringifyTransforms(transformStack: TransformStack): string { case 'focus-function': return `${shortKey}-${transform.funcIndex}`; case 'focus-category': + case 'drop-category': return `${shortKey}-${transform.category}`; case 'collapse-resource': return `${shortKey}-${transform.implementation}-${transform.resourceIndex}-${transform.collapsedFuncIndex}`; @@ -449,6 +467,16 @@ export function getTransformLabelL10nIds( }; } + if (transform.type === 'drop-category') { + if (categories === undefined) { + throw new Error('Expected categories to be defined.'); + } + return { + l10nId: 'TransformNavigator--drop-category', + item: categories[transform.category].name, + }; + } + if (transform.type === 'filter-samples') { switch (transform.filterType) { case 'marker-search': @@ -551,6 +579,12 @@ export function applyTransformToCallNodePath( callNodePath, callNodeInfo ); + case 'drop-category': + return _dropCategoryInCallNodePath( + transform.category, + callNodePath, + callNodeInfo + ); case 'merge-call-node': return _mergeNodeInCallNodePath(transform.callNodePath, callNodePath); case 'merge-function': @@ -672,6 +706,23 @@ function _removeOtherCategoryFunctionsInNodePathWithFunction( return newCallNodePath; } +// If the leaf node of the call node path belongs to the dropped category, +// return an empty path — the whole sample is gone. +function _dropCategoryInCallNodePath( + category: IndexIntoCategoryList, + callNodePath: CallNodePath, + callNodeInfo: CallNodeInfo +): CallNodePath { + const leafCallNodeIndex = callNodeInfo.getCallNodeIndexFromPath(callNodePath); + if ( + leafCallNodeIndex !== null && + callNodeInfo.categoryForNode(leafCallNodeIndex) === category + ) { + return []; + } + return callNodePath; +} + function _collapseResourceInCallNodePath( resourceIndex: IndexIntoResourceTable, collapsedFuncIndex: IndexIntoFuncTable, @@ -1456,6 +1507,20 @@ export function focusCategory(thread: Thread, category: IndexIntoCategoryList) { }); } +/** + * Drop any samples whose leaf stack frame belongs to the given category. + */ +export function dropCategory(thread: Thread, category: IndexIntoCategoryList) { + return timeCode('dropCategory', () => { + const { stackTable } = thread; + + return updateThreadStacks(thread, stackTable, (stack) => + // Drop any sample whose leaf frame belongs to the given category. + stack !== null && stackTable.category[stack] === category ? null : stack + ); + }); +} + /** * When restoring function in a CallNodePath there can be multiple correct CallNodePaths * that could be restored. The best approach would probably be to restore to the @@ -1834,6 +1899,8 @@ export function applyTransform( return focusSelf(thread, transform.funcIndex, transform.implementation); case 'focus-category': return focusCategory(thread, transform.category); + case 'drop-category': + return dropCategory(thread, transform.category); case 'collapse-resource': return collapseResource( thread, @@ -2059,7 +2126,8 @@ export function translateTransform( funcIndex: newFuncIndex, }; } - case 'focus-category': { + case 'focus-category': + case 'drop-category': { // We don't sanitize-away categories, so this transform doesn't need to // be translated. return transform; diff --git a/src/test/components/CallNodeContextMenu.test.tsx b/src/test/components/CallNodeContextMenu.test.tsx index 0e032e73cf..85252b7e99 100644 --- a/src/test/components/CallNodeContextMenu.test.tsx +++ b/src/test/components/CallNodeContextMenu.test.tsx @@ -125,7 +125,8 @@ describe('calltree/CallNodeContextMenu', function () { { matcher: /Merge node only/, type: 'merge-call-node' }, { matcher: /Focus on subtree only/, type: 'focus-subtree' }, { matcher: /Focus on function/, type: 'focus-function' }, - { matcher: /Other/, type: 'focus-category' }, + { matcher: /Focus on category/, type: 'focus-category' }, + { matcher: /Drop samples with category/, type: 'drop-category' }, { matcher: /Collapse function/, type: 'collapse-function-subtree' }, { matcher: /XUL/, type: 'collapse-resource' }, { @@ -136,7 +137,7 @@ describe('calltree/CallNodeContextMenu', function () { matcher: /Collapse direct recursion/, type: 'collapse-direct-recursion', }, - { matcher: /Drop samples/, type: 'drop-function' }, + { matcher: /Drop samples with this function/, type: 'drop-function' }, ]; fixtures.forEach(({ matcher, type }) => { diff --git a/src/test/components/TransformShortcuts.test.tsx b/src/test/components/TransformShortcuts.test.tsx index aefbcfbd4a..17d5f6464c 100644 --- a/src/test/components/TransformShortcuts.test.tsx +++ b/src/test/components/TransformShortcuts.test.tsx @@ -68,6 +68,15 @@ function testTransformKeyboardShortcuts(setup: () => TestSetup) { }); }); + it('handles drop-category', () => { + const { pressKey, getTransform, expectedCategory } = setup(); + pressKey({ key: 'G' }); + expect(getTransform()).toEqual({ + type: 'drop-category', + category: expectedCategory, + }); + }); + it('handles merge call node', () => { const { pressKey, getTransform, expectedCallNodePath } = setup(); pressKey({ key: 'M' }); diff --git a/src/test/components/__snapshots__/CallNodeContextMenu.test.tsx.snap b/src/test/components/__snapshots__/CallNodeContextMenu.test.tsx.snap index 083afe2a7b..5e950fb1a8 100644 --- a/src/test/components/__snapshots__/CallNodeContextMenu.test.tsx.snap +++ b/src/test/components/__snapshots__/CallNodeContextMenu.test.tsx.snap @@ -137,6 +137,30 @@ exports[`calltree/CallNodeContextMenu basic rendering renders a full context men g +
(c.name === 'Graphics' ? i : -1)) + .filter((i) => i !== -1)[0]; + + return { + threadIndex, + categoryIndex, + funcNamesDict, + ...storeWithProfile(profile), + }; + } + + describe('drops samples where the leaf frame is in the category, keeps others', function () { + // Sample 1: C (leaf) is Graphics -> entire sample dropped + // Sample 2: E (leaf) is Layout -> kept + const { threadIndex, categoryIndex, getState, dispatch } = setup(` + A[cat:Layout] A[cat:Layout] + B[cat:Layout] D[cat:Layout] + C[cat:Graphics] E[cat:Layout] + `); + const originalCallTree = selectedThreadSelectors.getCallTree(getState()); + + it('starts as an unfiltered call tree', function () { + expect(formatTree(originalCallTree)).toEqual([ + '- A (total: 2, self: —)', + ' - B (total: 1, self: —)', + ' - C (total: 1, self: 1)', + ' - D (total: 1, self: —)', + ' - E (total: 1, self: 1)', + ]); + }); + + it('drops the sample with a Graphics leaf, keeps the one without', function () { + dispatch( + addTransformToStack(threadIndex, { + type: 'drop-category', + category: categoryIndex, + }) + ); + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([ + '- A (total: 1, self: —)', + ' - D (total: 1, self: —)', + ' - E (total: 1, self: 1)', + ]); + }); + }); + + describe('does not drop a sample when only the root frame is in the category', function () { + const { threadIndex, categoryIndex, getState, dispatch } = setup(` + A[cat:Graphics] + B[cat:Layout] + `); + + it('keeps the sample since the leaf is not in the category', function () { + dispatch( + addTransformToStack(threadIndex, { + type: 'drop-category', + category: categoryIndex, + }) + ); + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([ + '- A (total: 1, self: —)', + ' - B (total: 1, self: 1)', + ]); + }); + }); + + describe('drops a sample when a leaf frame is in the category', function () { + const { threadIndex, categoryIndex, getState, dispatch } = setup(` + A[cat:Layout] + B[cat:Layout] + C[cat:Graphics] + `); + + it('results in an empty call tree', function () { + dispatch( + addTransformToStack(threadIndex, { + type: 'drop-category', + category: categoryIndex, + }) + ); + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([]); + }); + }); + + describe('selected call node path is cleared when the leaf is in the category', function () { + const { threadIndex, categoryIndex, getState, dispatch, funcNamesDict } = + setup(` + A[cat:Layout] A[cat:Layout] + B[cat:Layout] D[cat:Layout] + C[cat:Graphics] E[cat:Layout] + `); + + it('clears selected path when the leaf node is in the Graphics category', function () { + const { A, B, C } = funcNamesDict; + dispatch(changeSelectedCallNode(threadIndex, [A, B, C])); + dispatch( + addTransformToStack(threadIndex, { + type: 'drop-category', + category: categoryIndex, + }) + ); + const selectedCallNodePath = + selectedThreadSelectors.getSelectedCallNodePath(getState()); + expect(selectedCallNodePath).toEqual([]); + }); + }); +}); + describe('"collapse-resource" transform', function () { describe('combined implementation', function () { /** diff --git a/src/test/url-handling.test.ts b/src/test/url-handling.test.ts index 88ff994ee0..a046c24558 100644 --- a/src/test/url-handling.test.ts +++ b/src/test/url-handling.test.ts @@ -1308,7 +1308,7 @@ describe('url upgrading', function () { describe('URL serialization of the transform stack', function () { const transformString = - 'f-combined-0w2~mcn-combined-2w4~f-js-3w5-i~mf-6~ff-7~fg-42~cr-combined-8-9~' + + 'f-combined-0w2~mcn-combined-2w4~f-js-3w5-i~mf-6~ff-7~fg-42~dg-43~cr-combined-8-9~' + 'drec-combined-10~rec-11~df-12~cfs-13'; const { getState } = _getStoreWithURL({ search: '?transforms=' + transformString, @@ -1349,6 +1349,10 @@ describe('URL serialization of the transform stack', function () { type: 'focus-category', category: 42, }, + { + type: 'drop-category', + category: 43, + }, { type: 'collapse-resource', resourceIndex: 8, diff --git a/src/types/transforms.ts b/src/types/transforms.ts index 0837ced0d7..17b9d3fdfe 100644 --- a/src/types/transforms.ts +++ b/src/types/transforms.ts @@ -368,6 +368,32 @@ export type TransformDefinitions = { readonly category: IndexIntoCategoryList; }; + /** + * Drop any samples whose leaf stack frame belongs to the specified category. + * Only the leaf (innermost) frame is checked — if a non-leaf frame belongs to + * the category the sample is kept. An example with 'function:category' as the + * node name, dropping the Native category: + * + * A:JS A:JS + * / \ \ + * v v Drop Native v + * B:JS E:JS -> E:JS + * | | | + * v v v + * C:Native B:JS B:JS + * | | + * v v + * D:JS D:JS + * + * The left branch (B:JS → C:Native) is removed because the leaf C:Native + * belongs to the Native category. The right branch is kept in full because + * its leaf D:JS is not Native, even though no Native frames appear there. + */ + 'drop-category': { + readonly type: 'drop-category'; + readonly category: IndexIntoCategoryList; + }; + /** * Filter the samples in the thread by the filter. * Currently it only supports filtering by the marker name but can be extended diff --git a/src/utils/types.ts b/src/utils/types.ts index 1d6df05f5e..006ab88fdc 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -86,6 +86,7 @@ export function convertToTransformType(type: string): TransformType | null { case 'focus-subtree': case 'focus-function': case 'focus-category': + case 'drop-category': case 'focus-self': case 'collapse-resource': case 'collapse-direct-recursion': From f3c0f2991f03a042cdd021b86bdb22685dc703de Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 25 Jul 2025 21:14:45 -0400 Subject: [PATCH 02/63] Add utilities for summarizing CPU usage timelines in a textual form. --- src/profile-logic/combined-cpu.ts | 133 ++++++++++++++ src/selectors/per-thread/thread.tsx | 17 ++ src/selectors/profile.ts | 106 +++++++++++ src/test/store/profile-cpu.test.ts | 16 ++ src/test/unit/activity-slice-tree.test.js | 75 ++++++++ src/test/unit/combined-cpu.test.ts | 98 ++++++++++ src/utils/slice-tree.ts | 206 ++++++++++++++++++++++ src/utils/window-console.ts | 10 ++ 8 files changed, 661 insertions(+) create mode 100644 src/profile-logic/combined-cpu.ts create mode 100644 src/test/store/profile-cpu.test.ts create mode 100644 src/test/unit/activity-slice-tree.test.js create mode 100644 src/test/unit/combined-cpu.test.ts create mode 100644 src/utils/slice-tree.ts diff --git a/src/profile-logic/combined-cpu.ts b/src/profile-logic/combined-cpu.ts new file mode 100644 index 0000000000..35999f2684 --- /dev/null +++ b/src/profile-logic/combined-cpu.ts @@ -0,0 +1,133 @@ +/* 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/. */ + +import type { SamplesTable } from 'firefox-profiler/types'; +import { bisectionLeft } from '../utils/bisect'; + +/** + * Represents CPU usage over time for a single thread. + */ +export type CpuRatioTimeSeries = { + time: number[]; + cpuRatio: Float64Array; + maxCpuRatio: number; + length: number; +}; + +/** + * Combines CPU usage data from multiple threads into a single timeline. + * + * This function takes CPU ratio data from multiple threads, each with potentially + * different sampling times, and creates a unified timeline where CPU ratios are + * summed. The result can exceed 1.0 when multiple threads are active simultaneously. + * + * The algorithm: + * 1. Maintains a cursor for each thread tracking the current sample index + * 2. Processes all sample times in ascending order (using a min-heap approach) + * 3. For each time point, sums CPU ratios from threads that are active at that time + * 4. A thread is considered active only between its first and last sample times + * + * Note: cpuRatio[i] represents CPU usage between time[i-1] and time[i], so we don't + * extend a thread's CPU usage beyond its last sample time. + * + * @param threadSamples - Array of SamplesTable objects, one per thread + * @param rangeStart - Optional start time to filter samples (inclusive) + * @param rangeEnd - Optional end time to filter samples (exclusive) + * @returns Combined CPU data with unified time array and summed CPU ratios, + * or null if no threads have CPU data + */ +export function combineCPUDataFromThreads( + threadSamples: SamplesTable[], + rangeStart?: number, + rangeEnd?: number +): CpuRatioTimeSeries | null { + // Filter threads that have CPU ratio data. + // We require at least two samples per thread; the first sample's CPU ratio + // is meaningless. threadCPUPercent[1] is the CPU percentage between + // samples.time[0] and samples.time[1]. + const threadsWithCPU: CpuRatioTimeSeries[] = []; + for (const samples of threadSamples) { + if (samples.hasCPUDeltas && samples.time.length >= 2) { + let time = samples.time; + let cpuRatio = Float64Array.from( + samples.threadCPUPercent.subarray(0, samples.length), + (v) => v / 100 + ); + let length = samples.length; + + if (rangeStart !== undefined && rangeEnd !== undefined) { + const startIndex = bisectionLeft(samples.time, rangeStart); + const endIndex = bisectionLeft(samples.time, rangeEnd, startIndex); + + if (startIndex < endIndex) { + time = samples.time.slice(startIndex, endIndex); + cpuRatio = Float64Array.from( + samples.threadCPUPercent.subarray(startIndex, endIndex), + (v) => v / 100 + ); + length = endIndex - startIndex; + } else { + continue; + } + } + + threadsWithCPU.push({ + time, + cpuRatio, + maxCpuRatio: Infinity, + length, + }); + } + } + + if (threadsWithCPU.length === 0) { + return null; + } + + const cursors = new Array(threadsWithCPU.length).fill(0); + const combinedTime: number[] = []; + const combinedCPURatio: number[] = []; + let combinedMaxCpuRatio = 0; + + while (true) { + let sampleTime = Infinity; + for (let threadIdx = 0; threadIdx < threadsWithCPU.length; threadIdx++) { + const cursor = cursors[threadIdx]; + const thread = threadsWithCPU[threadIdx]; + if (cursor < thread.time.length) { + sampleTime = Math.min(sampleTime, thread.time[cursor]); + } + } + + if (sampleTime === Infinity) { + break; + } + + let sumCPURatio = 0; + for (let threadIdx = 0; threadIdx < threadsWithCPU.length; threadIdx++) { + const thread = threadsWithCPU[threadIdx]; + const cursor = cursors[threadIdx]; + if (cursor === thread.time.length) { + continue; + } + if (cursor > 0) { + sumCPURatio += thread.cpuRatio[cursor]; + } + if (thread.time[cursor] === sampleTime) { + cursors[threadIdx]++; + } + } + + combinedTime.push(sampleTime); + combinedCPURatio.push(sumCPURatio); + combinedMaxCpuRatio = Math.max(combinedMaxCpuRatio, sumCPURatio); + } + + return { + time: combinedTime, + cpuRatio: Float64Array.from(combinedCPURatio), + maxCpuRatio: combinedMaxCpuRatio, + length: combinedTime.length, + }; +} diff --git a/src/selectors/per-thread/thread.tsx b/src/selectors/per-thread/thread.tsx index 8f62a266fa..ee5c8db852 100644 --- a/src/selectors/per-thread/thread.tsx +++ b/src/selectors/per-thread/thread.tsx @@ -51,6 +51,8 @@ import type { MarkerSelectorsPerThread } from './markers'; import { mergeThreads } from '../../profile-logic/merge-compare'; import { defaultThreadViewOptions } from '../../reducers/profile-view'; +import type { SliceTree } from '../../utils/slice-tree'; +import { getSlices } from '../../utils/slice-tree'; // Memoize some of these functions globally, so that in the common case we only // need to do these computations once globally instead of per thread. These @@ -127,6 +129,20 @@ export function getBasicThreadSelectorsPerThread( ProfileSelectors.getDefaultCategory, ProfileData.computeSamplesTableFromRawSamplesTable ); + const getActivitySlices: Selector = createSelector( + getSamplesTable, + (samples) => + samples.hasCPUDeltas + ? getSlices( + [0.05, 0.2, 0.4, 0.6, 0.8], + Float64Array.from( + samples.threadCPUPercent.subarray(0, samples.length), + (v) => v / 100 + ), + samples.time + ) + : null + ); const getNativeAllocations: Selector = ( state ) => getRawThread(state).nativeAllocations; @@ -399,6 +415,7 @@ export function getBasicThreadSelectorsPerThread( getThread, getSamplesTable, getTracedValuesBuffer, + getActivitySlices, getSamplesWeightType, getNativeAllocations, getJsAllocations, diff --git a/src/selectors/profile.ts b/src/selectors/profile.ts index 7ac0ef4f2c..34253ae15e 100644 --- a/src/selectors/profile.ts +++ b/src/selectors/profile.ts @@ -4,7 +4,10 @@ import { createSelector } from 'reselect'; import * as Tracks from '../profile-logic/tracks'; import * as CPU from '../profile-logic/cpu'; +import * as CombinedCPU from '../profile-logic/combined-cpu'; import * as UrlState from './url-state'; +import type { SliceTree } from '../utils/slice-tree'; +import { getSlices } from '../utils/slice-tree'; import { ensureExists } from '../utils/types'; import { accumulateCounterSamples, @@ -16,6 +19,7 @@ import { computeTabToThreadIndexesMap, computeStackTableFromRawStackTable, reserveFunctionsForCollapsedResources, + computeSamplesTableFromRawSamplesTable, } from '../profile-logic/profile-data'; import type { IPCMarkerCorrelations } from '../profile-logic/marker-data'; import { correlateIPCMarkers } from '../profile-logic/marker-data'; @@ -68,6 +72,7 @@ import type { MarkerSchema, MarkerSchemaByName, SampleUnits, + SamplesTable, IndexIntoSamplesTable, ExtraProfileInfoSection, TableViewOptions, @@ -708,6 +713,107 @@ export const getThreadActivityScores: Selector> = } ); +/** + * Get the CPU time in milliseconds for each thread. + * Returns an array of CPU times (one per thread), or null if no CPU delta + * information is available. This uses the raw sampleScore without boost factors. + */ +export const getThreadCPUTimeMs: Selector | null> = + createSelector(getProfile, (profile) => { + const { threads, meta } = profile; + const { sampleUnits } = meta; + + if (!sampleUnits || !sampleUnits.threadCPUDelta) { + return null; + } + + // Determine the conversion factor to milliseconds + let cpuDeltaToMs: number; + switch (sampleUnits.threadCPUDelta) { + case 'µs': + cpuDeltaToMs = 1 / 1000; + break; + case 'ns': + cpuDeltaToMs = 1 / 1000000; + break; + case 'variable CPU cycles': + // CPU cycles are not time units, return null + return null; + default: + return null; + } + + return threads.map((thread) => { + const { threadCPUDelta } = thread.samples; + if (!threadCPUDelta) { + return 0; + } + + // Ignore the first delta because it has no preceding sample interval. + const totalCPUDelta = threadCPUDelta + .slice(1) + .reduce((accum, delta) => accum + (delta ?? 0), 0); + return totalCPUDelta * cpuDeltaToMs; + }); + }); + +/** + * Get SamplesTable for all threads in the profile. + * Returns an array of SamplesTable objects, one per thread. + */ +export const getAllThreadsSamplesTables: Selector = + createSelector( + getProfile, + getStackTable, + getSampleUnits, + getReferenceCPUDeltaPerMs, + getDefaultCategory, + ( + profile, + stackTable, + sampleUnits, + referenceCPUDeltaPerMs, + defaultCategory + ) => { + return profile.threads.map((thread) => + computeSamplesTableFromRawSamplesTable( + thread.samples, + stackTable, + sampleUnits, + referenceCPUDeltaPerMs, + defaultCategory + ) + ); + } + ); + +/** + * Get combined CPU activity data from all threads. + * Returns combined time and CPU ratio arrays, or null if no CPU data is available. + */ +export const getCombinedThreadCPUData: Selector = + createSelector(getAllThreadsSamplesTables, (samplesTables) => + CombinedCPU.combineCPUDataFromThreads(samplesTables) + ); + +/** + * Get activity slices for the combined CPU usage across all threads. + * Returns hierarchical slices showing periods of high combined CPU activity, + * or null if no CPU data is available. + */ +export const getCombinedThreadActivitySlices: Selector = + createSelector(getCombinedThreadCPUData, (combinedCPU) => { + if (combinedCPU === null || combinedCPU.maxCpuRatio === 0) { + return null; + } + const m = Math.ceil(combinedCPU.maxCpuRatio); + return getSlices( + [0.05 * m, 0.2 * m, 0.4 * m, 0.6 * m, 0.8 * m], + combinedCPU.cpuRatio, + combinedCPU.time + ); + }); + /** * Get the pages array and construct a Map of pages that we can use to get the * relationships of tabs. The constructed map is `Map`. diff --git a/src/test/store/profile-cpu.test.ts b/src/test/store/profile-cpu.test.ts new file mode 100644 index 0000000000..2bcb310473 --- /dev/null +++ b/src/test/store/profile-cpu.test.ts @@ -0,0 +1,16 @@ +/* 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/. */ + +import * as ProfileSelectors from '../../selectors/profile'; +import { storeWithProfile } from '../fixtures/stores'; +import { getProfileWithThreadCPUDelta } from '../fixtures/profiles/processed-profile'; + +describe('profile CPU selectors', function () { + it('ignores the first threadCPUDelta entry when summing CPU time', function () { + const profile = getProfileWithThreadCPUDelta([[7000, 11000, 13000]], 'ns'); + const { getState } = storeWithProfile(profile); + + expect(ProfileSelectors.getThreadCPUTimeMs(getState())).toEqual([0.024]); + }); +}); diff --git a/src/test/unit/activity-slice-tree.test.js b/src/test/unit/activity-slice-tree.test.js new file mode 100644 index 0000000000..d87eb512f0 --- /dev/null +++ b/src/test/unit/activity-slice-tree.test.js @@ -0,0 +1,75 @@ +/* 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/. */ + +// @flow + +import { getSlices, printSliceTree } from '../../utils/slice-tree'; + +function getSlicesEasy(threadCPUPercentage: number[]): string[] { + const time = threadCPUPercentage.map((_, i) => i); + const cpuRatio = new Float64Array(threadCPUPercentage.map((p) => p / 100)); + const slices = getSlices([0.05, 0.2, 0.4, 0.6, 0.8], cpuRatio, time); + return printSliceTree(slices); +} + +describe('Activity slice tree', function () { + it('allocates the right amount of slots', function () { + expect(getSlicesEasy([0, 0, 6, 0, 0, 0])).toEqual([ + '- 6% for 1.0ms (1 samples): 1.0ms - 2.0ms', + ]); + expect(getSlicesEasy([0, 0, 100, 0, 100, 0, 100, 0, 0, 0])).toEqual([ + '- 60% for 5.0ms (5 samples): 1.0ms - 6.0ms', + ' - 100% for 1.0ms (1 samples): 1.0ms - 2.0ms', + ' - 100% for 1.0ms (1 samples): 3.0ms - 4.0ms', + ' - 100% for 1.0ms (1 samples): 5.0ms - 6.0ms', + ]); + expect( + getSlicesEasy([ + 0, 0, 6, 0, 0, 0, 0, 34, 86, 34, 0, 0, 0, 0, 12, 9, 0, 0, 0, 7, 0, + ]) + ).toEqual([ + '- 10% for 18.0ms (18 samples): 1.0ms - 19.0ms', + ' - 51% for 3.0ms (3 samples): 6.0ms - 9.0ms', + ' - 86% for 1.0ms (1 samples): 7.0ms - 8.0ms', + ]); + }); + + it('keeps ancestors of interesting child slices', function () { + const slices = [ + { start: 0, end: 1, avg: 0.1, sum: 1, parent: null }, + ...Array.from({ length: 19 }, () => ({ + start: 0, + end: 1, + avg: 0.2, + sum: 10, + parent: null, + })), + { start: 0, end: 1, avg: 0.9, sum: 1000, parent: 0 }, + ]; + + expect(printSliceTree({ slices, time: [0, 1] })).toEqual([ + '- 10% for 1.0ms (1 samples): 0.0ms - 1.0ms', + ' - 90% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + '- 20% for 1.0ms (1 samples): 0.0ms - 1.0ms', + ]); + }); +}); diff --git a/src/test/unit/combined-cpu.test.ts b/src/test/unit/combined-cpu.test.ts new file mode 100644 index 0000000000..87fbc6501a --- /dev/null +++ b/src/test/unit/combined-cpu.test.ts @@ -0,0 +1,98 @@ +/* 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/. */ + +import { combineCPUDataFromThreads } from 'firefox-profiler/profile-logic/combined-cpu'; +import type { SamplesTable } from 'firefox-profiler/types'; + +function createSamplesTable(time: number[], cpuRatio: number[]): SamplesTable { + // threadCPUPercent has length + 1 elements; the extra element covers "after last sample" + const percentValues = cpuRatio.map((v) => Math.round(v * 100)); + percentValues.push(0); + return { + time, + threadCPUPercent: Uint8Array.from(percentValues), + hasCPUDeltas: true, + // Other required fields (stubbed for test purposes) + stack: new Array(time.length).fill(null), + length: time.length, + weight: null, + weightType: 'samples', + category: new Uint8Array(time.length), + subcategory: new Uint8Array(time.length), + }; +} + +describe('combineCPUDataFromThreads', function () { + it('returns null when given empty array', function () { + const result = combineCPUDataFromThreads([]); + expect(result).toBeNull(); + }); + + it('returns single thread data unchanged for one thread', function () { + const samples = [createSamplesTable([0, 100, 200], [0.0, 0.5, 0.8])]; + + const result = combineCPUDataFromThreads(samples); + + expect(result).not.toBeNull(); + expect(result!.time).toEqual([0, 100, 200]); + expect(Array.from(result!.cpuRatio)).toEqual([0.0, 0.5, 0.8]); + }); + + it('combines two threads with same sample times', function () { + const samples = [ + createSamplesTable([0, 100, 200], [0, 0.5, 0.3]), + createSamplesTable([0, 100, 200], [0, 0.4, 0.5]), + ]; + + const result = combineCPUDataFromThreads(samples); + + expect(result).not.toBeNull(); + expect(result!.time).toEqual([0, 100, 200]); + expect(Array.from(result!.cpuRatio)).toEqual([0, 0.9, 0.8]); + }); + + it('combines threads with different sample times', function () { + const samples = [ + createSamplesTable([0, 100, 200], [0.0, 0.5, 0.8]), + createSamplesTable([50, 150, 250], [0.0, 0.3, 0.4]), + ]; + + const result = combineCPUDataFromThreads(samples); + + expect(result).not.toBeNull(); + // Should have all unique time points + expect(result!.time).toEqual([0, 50, 100, 150, 200, 250]); + + // 0: thread1=bef, thread2=bef → 0.0 + // 0- 50: thread1=0.5, thread2=bef → 0.5 + // 50-100: thread1=0.5, thread2=0.3 → 0.8 + // 100-150: thread1=0.8, thread2=0.3 → 1.1 + // 150-200: thread1=0.8, thread2=0.4 → 1.2 + // 200-250: thread1=end, thread2=0.4 → 0.4 + const expected = [0.0, 0.5, 0.8, 1.1, 1.2, 0.4]; + const actual = Array.from(result!.cpuRatio); + expect(actual.length).toBe(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(actual[i]).toBeCloseTo(expected[i], 10); + } + }); + + it('handles threads with non-overlapping time ranges', function () { + const samples = [ + createSamplesTable([0, 10, 20], [0.0, 0.3, 0.5]), + createSamplesTable([30, 40, 50], [0.0, 0.4, 0.6]), + ]; + + const result = combineCPUDataFromThreads(samples); + + expect(result).not.toBeNull(); + expect(result!.time).toEqual([0, 10, 20, 30, 40, 50]); + + // At times 0, 10, 20: only thread1 has samples + // At times 30, 40, 50: thread1 has ended (30 > 20), only thread2 contributes + expect(Array.from(result!.cpuRatio)).toEqual([ + 0.0, 0.3, 0.5, 0.0, 0.4, 0.6, + ]); + }); +}); diff --git a/src/utils/slice-tree.ts b/src/utils/slice-tree.ts new file mode 100644 index 0000000000..d3a554666e --- /dev/null +++ b/src/utils/slice-tree.ts @@ -0,0 +1,206 @@ +/* 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/. */ + +export type Slice = { + start: number; + end: number; + avg: number; + sum: number; + parent: number | null; +}; + +function addIndexIntervalsExceedingThreshold( + threshold: number, + cpuRatio: Float64Array, + time: number[], + items: Slice[], + parent: number | null, + startIndex: number = 0, + endIndex: number = cpuRatio.length - 1 +) { + let currentStartIndex = startIndex; + while (true) { + let currentEndIndex = endIndex; + while ( + currentStartIndex < currentEndIndex && + cpuRatio[currentStartIndex + 1] < threshold + ) { + currentStartIndex++; + } + + while ( + currentStartIndex < currentEndIndex && + cpuRatio[currentEndIndex] < threshold + ) { + currentEndIndex--; + } + + if (currentStartIndex === currentEndIndex) { + break; + } + + const startTime = time[currentStartIndex]; + let sum = 0; + let lastEndIndexWithAvgExceedingThreshold = currentStartIndex + 1; + let lastEndIndexWithAvgExceedingThresholdAvg = threshold; + let lastEndIndexWithAvgExceedingThresholdSum = 0; + let timeBefore = startTime; + for (let i = currentStartIndex + 1; i <= currentEndIndex; i++) { + const timeAfter = time[i]; + const timeDelta = timeAfter - timeBefore; + sum += cpuRatio[i] * timeDelta; + if (timeAfter > startTime) { + const avg = sum / (timeAfter - startTime); + if (avg >= threshold) { + lastEndIndexWithAvgExceedingThreshold = i; + lastEndIndexWithAvgExceedingThresholdAvg = avg; + lastEndIndexWithAvgExceedingThresholdSum = sum; + } + } + timeBefore = timeAfter; + } + + // assert(currentStartIndex < lastEndIndexWithAvgExceedingThreshold); + items.push({ + start: currentStartIndex, + end: lastEndIndexWithAvgExceedingThreshold, + avg: lastEndIndexWithAvgExceedingThresholdAvg, + sum: lastEndIndexWithAvgExceedingThresholdSum, + parent, + }); + currentStartIndex = lastEndIndexWithAvgExceedingThreshold; + } +} + +export type SliceTree = { + slices: Slice[]; + time: number[]; +}; + +export function getSlices( + thresholds: number[], + cpuRatio: Float64Array, + time: number[], + startIndex: number = 0, + endIndex: number = cpuRatio.length - 1 +): SliceTree { + const firstThreshold = thresholds[0]; + const slices = new Array(); + addIndexIntervalsExceedingThreshold( + firstThreshold, + cpuRatio, + time, + slices, + null, + startIndex, + endIndex + ); + for (let i = 0; i < slices.length; i++) { + const slice = slices[i]; + const nextThreshold = thresholds.find((thresh) => thresh > slice.avg); + if (nextThreshold === undefined) { + continue; + } + addIndexIntervalsExceedingThreshold( + nextThreshold, + cpuRatio, + time, + slices, + i, + slice.start, + slice.end + ); + } + return { slices, time }; +} + +function sliceToString(slice: Slice, time: number[]): string { + const { avg, start, end } = slice; + const startTime = time[start]; + const endTime = time[end]; + const duration = endTime - startTime; + const sampleCount = end - start; + return `${Math.round(avg * 100)}% for ${duration.toFixed(1)}ms (${sampleCount} samples): ${startTime.toFixed(1)}ms - ${endTime.toFixed(1)}ms`; +} + +function appendSliceSubtree( + slices: Slice[], + startIndex: number, + parent: number | null, + childrenStartPerParent: Array, + interestingSliceIndexes: Set, + nestingDepth: number, + time: number[], + s: string[] +) { + for (let i = startIndex; i < slices.length; i++) { + if (!interestingSliceIndexes.has(i)) { + continue; + } + + const slice = slices[i]; + if (slice.parent !== parent) { + break; + } + + s.push(' '.repeat(nestingDepth) + '- ' + sliceToString(slice, time)); + + const childrenStart = childrenStartPerParent[i]; + if (childrenStart !== null) { + appendSliceSubtree( + slices, + childrenStart, + i, + childrenStartPerParent, + interestingSliceIndexes, + nestingDepth + 1, + time, + s + ); + } + } +} + +export function printSliceTree({ slices, time }: SliceTree): string[] { + if (slices.length === 0) { + return ['No significant activity.']; + } + + const childrenStartPerParent = new Array(slices.length); + const indexAndSumPerSlice = []; + for (let i = 0; i < slices.length; i++) { + childrenStartPerParent[i] = null; + const { parent, sum } = slices[i]; + indexAndSumPerSlice.push({ i, sum }); + if (parent !== null && childrenStartPerParent[parent] === null) { + childrenStartPerParent[parent] = i; + } + } + indexAndSumPerSlice.sort((a, b) => b.sum - a.sum); + const interestingSliceIndexes = new Set(); + for (const { i } of indexAndSumPerSlice.slice(0, 20)) { + let currentIndex: number | null = i; + while ( + currentIndex !== null && + !interestingSliceIndexes.has(currentIndex) + ) { + interestingSliceIndexes.add(currentIndex); + currentIndex = slices[currentIndex].parent; + } + } + + const s = new Array(); + appendSliceSubtree( + slices, + 0, + null, + childrenStartPerParent, + interestingSliceIndexes, + 0, + time, + s + ); + + return s; +} diff --git a/src/utils/window-console.ts b/src/utils/window-console.ts index 96bb9c74b3..7d792200fb 100644 --- a/src/utils/window-console.ts +++ b/src/utils/window-console.ts @@ -22,6 +22,7 @@ import { getThemePreference, setThemePreference, } from 'firefox-profiler/utils/dark-mode'; +import { printSliceTree } from 'firefox-profiler/utils/slice-tree'; import type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; // Despite providing a good libdef for Object.defineProperty, Flow still @@ -53,6 +54,7 @@ export type ExtraPropertiesOnWindowForConsole = { ) => Promise; extractGeckoLogs: () => string; totalMarkerDuration: (markers: any) => number; + activity: () => void; shortenUrl: typeof shortenUrl; getState: GetState; selectors: typeof selectorsForConsole; @@ -366,6 +368,14 @@ export function addDataToWindowObject( return totalDuration; }; + target.activity = function () { + const slices = + selectorsForConsole.selectedThread.getActivitySlices(getState()); + if (slices) { + console.log(printSliceTree(slices).join('\n')); + } + }; + target.shortenUrl = shortenUrl; target.getState = getState; target.selectors = selectorsForConsole; From c2fd1c8f432aed3fa37f4786edc852bf1de79d55 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 25 Jul 2025 21:14:45 -0400 Subject: [PATCH 03/63] Create a profile-query library and a profile-query-cli script. --- .gitignore | 1 + jest.config.js | 100 +- marker-support-plan.md | 816 +++++++++ package.json | 8 +- pq-case-study-2.md | 527 ++++++ pq-case-study.md | 718 ++++++++ pq-filters-and-bookmarks-proposal.md | 750 ++++++++ pq-todo.md | 488 ++++++ scripts/build-profile-query-cli.mjs | 30 + scripts/build-profile-query.mjs | 19 + src/profile-logic/call-tree.ts | 4 + src/profile-query-cli/.npmignore | 32 + src/profile-query-cli/README.md | 302 ++++ src/profile-query-cli/client.ts | 287 ++++ src/profile-query-cli/constants.ts | 12 + src/profile-query-cli/daemon.ts | 412 +++++ src/profile-query-cli/formatters.ts | 859 ++++++++++ src/profile-query-cli/index.ts | 664 +++++++ src/profile-query-cli/package.json | 35 + src/profile-query-cli/protocol.ts | 128 ++ src/profile-query-cli/session.ts | 202 +++ src/profile-query-cli/tests/basic.test.ts | 95 ++ .../tests/daemon-startup.test.ts | 116 ++ src/profile-query-cli/tests/sessions.test.ts | 117 ++ src/profile-query-cli/tests/setup.ts | 7 + src/profile-query-cli/tests/utils.ts | 161 ++ src/profile-query/README.md | 58 + src/profile-query/cpu-activity.ts | 208 +++ src/profile-query/formatters/call-tree.ts | 340 ++++ src/profile-query/formatters/marker-info.ts | 1518 +++++++++++++++++ src/profile-query/formatters/profile-info.ts | 114 ++ src/profile-query/formatters/thread-info.ts | 493 ++++++ src/profile-query/function-list.ts | 525 ++++++ src/profile-query/function-map.ts | 69 + src/profile-query/index.ts | 679 ++++++++ src/profile-query/loader.ts | 131 ++ src/profile-query/marker-map.ts | 72 + src/profile-query/process-thread-list.ts | 178 ++ src/profile-query/thread-map.ts | 47 + src/profile-query/time-range-parser.ts | 63 + src/profile-query/timestamps.ts | 312 ++++ src/profile-query/types.ts | 428 +++++ src/selectors/per-thread/thread.tsx | 20 + src/selectors/profile.ts | 33 + .../call-tree-formatting.test.ts.snap | 318 ++++ .../call-tree-formatting.test.ts | 617 +++++++ .../unit/profile-query-cli/client.test.ts | 28 + .../unit/profile-query-cli/daemon.test.ts | 28 + .../unit/profile-query-cli/session.test.ts | 355 ++++ .../unit/profile-query-marker-utils.test.ts | 208 +++ src/test/unit/profile-query/call-tree.test.ts | 589 +++++++ .../unit/profile-query/function-list.test.ts | 608 +++++++ .../profile-query/process-thread-list.test.ts | 379 ++++ .../profile-query/profile-querier.test.ts | 210 +++ .../profile-query/time-range-parser.test.ts | 145 ++ .../unit/profile-query/timestamps.test.ts | 133 ++ 56 files changed, 15759 insertions(+), 37 deletions(-) create mode 100644 marker-support-plan.md create mode 100644 pq-case-study-2.md create mode 100644 pq-case-study.md create mode 100644 pq-filters-and-bookmarks-proposal.md create mode 100644 pq-todo.md create mode 100644 scripts/build-profile-query-cli.mjs create mode 100644 scripts/build-profile-query.mjs create mode 100644 src/profile-query-cli/.npmignore create mode 100644 src/profile-query-cli/README.md create mode 100644 src/profile-query-cli/client.ts create mode 100644 src/profile-query-cli/constants.ts create mode 100644 src/profile-query-cli/daemon.ts create mode 100644 src/profile-query-cli/formatters.ts create mode 100644 src/profile-query-cli/index.ts create mode 100644 src/profile-query-cli/package.json create mode 100644 src/profile-query-cli/protocol.ts create mode 100644 src/profile-query-cli/session.ts create mode 100644 src/profile-query-cli/tests/basic.test.ts create mode 100644 src/profile-query-cli/tests/daemon-startup.test.ts create mode 100644 src/profile-query-cli/tests/sessions.test.ts create mode 100644 src/profile-query-cli/tests/setup.ts create mode 100644 src/profile-query-cli/tests/utils.ts create mode 100644 src/profile-query/README.md create mode 100644 src/profile-query/cpu-activity.ts create mode 100644 src/profile-query/formatters/call-tree.ts create mode 100644 src/profile-query/formatters/marker-info.ts create mode 100644 src/profile-query/formatters/profile-info.ts create mode 100644 src/profile-query/formatters/thread-info.ts create mode 100644 src/profile-query/function-list.ts create mode 100644 src/profile-query/function-map.ts create mode 100644 src/profile-query/index.ts create mode 100644 src/profile-query/loader.ts create mode 100644 src/profile-query/marker-map.ts create mode 100644 src/profile-query/process-thread-list.ts create mode 100644 src/profile-query/thread-map.ts create mode 100644 src/profile-query/time-range-parser.ts create mode 100644 src/profile-query/timestamps.ts create mode 100644 src/profile-query/types.ts create mode 100644 src/test/unit/profile-query-cli/__snapshots__/call-tree-formatting.test.ts.snap create mode 100644 src/test/unit/profile-query-cli/call-tree-formatting.test.ts create mode 100644 src/test/unit/profile-query-cli/client.test.ts create mode 100644 src/test/unit/profile-query-cli/daemon.test.ts create mode 100644 src/test/unit/profile-query-cli/session.test.ts create mode 100644 src/test/unit/profile-query-marker-utils.test.ts create mode 100644 src/test/unit/profile-query/call-tree.test.ts create mode 100644 src/test/unit/profile-query/function-list.test.ts create mode 100644 src/test/unit/profile-query/process-thread-list.test.ts create mode 100644 src/test/unit/profile-query/profile-querier.test.ts create mode 100644 src/test/unit/profile-query/time-range-parser.test.ts create mode 100644 src/test/unit/profile-query/timestamps.test.ts diff --git a/.gitignore b/.gitignore index 6d621273ed..82ebba8022 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ webpack.local-config.js *.orig *.rej .idea/ +.pq-dev/ diff --git a/jest.config.js b/jest.config.js index 67cbc9e4ca..59ac61ffcd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,45 +3,75 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ module.exports = { - testMatch: ['/src/**/*.test.{js,jsx,ts,tsx}'], - moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], + projects: [ + // ======================================================================== + // Browser Tests (React/browser environment) + // ======================================================================== + { + displayName: 'browser', + testMatch: ['/src/**/*.test.{js,jsx,ts,tsx}'], + moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], - // Use custom resolver that respects the "browser" field in package.json - resolver: './jest-resolver.js', + // Use custom resolver that respects the "browser" field in package.json + resolver: './jest-resolver.js', - testEnvironment: './src/test/custom-environment', - setupFilesAfterEnv: ['jest-extended/all', './src/test/setup.ts'], + testEnvironment: './src/test/custom-environment', + setupFilesAfterEnv: ['jest-extended/all', './src/test/setup.ts'], - collectCoverageFrom: [ - 'src/**/*.{js,jsx,ts,tsx}', - '!**/node_modules/**', - '!src/types/libdef/**', - ], + collectCoverageFrom: [ + 'src/**/*.{js,jsx,ts,tsx}', + '!**/node_modules/**', + '!src/types/libdef/**', + ], - transform: { - '\\.([jt]sx?|mjs)$': 'babel-jest', - }, + transform: { + '\\.([jt]sx?|mjs)$': 'babel-jest', + }, - // Transform ESM modules to CommonJS for Jest - // These packages ship as pure ESM and need to be transformed by Babel - transformIgnorePatterns: [ - '/node_modules/(?!(query-string|decode-uri-component|iongraph-web|split-on-first|filter-obj|fetch-mock|devtools-reps)/)', - ], + // Transform ESM modules to CommonJS for Jest + // These packages ship as pure ESM and need to be transformed by Babel + transformIgnorePatterns: [ + '/node_modules/(?!(query-string|decode-uri-component|iongraph-web|split-on-first|filter-obj|fetch-mock|devtools-reps)/)', + ], + + // Mock static assets (images, CSS, etc.) + moduleNameMapper: { + '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|ftl)$': + '/src/test/fixtures/mocks/file-mock.ts', + '\\.(css|less)$': '/src/test/fixtures/mocks/style-mock.ts', + }, + + globals: { + AVAILABLE_STAGING_LOCALES: null, + }, + + snapshotFormat: { + escapeString: true, + printBasicPrototype: true, + }, + }, + + // ======================================================================== + // CLI Tests (Node.js environment) + // ======================================================================== + { + displayName: 'cli', + testMatch: ['/src/profile-query-cli/tests/**/*.test.ts'], - // Mock static assets (images, CSS, etc.) - moduleNameMapper: { - '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|ftl)$': - '/src/test/fixtures/mocks/file-mock.ts', - '\\.(css|less)$': '/src/test/fixtures/mocks/style-mock.ts', - }, - - globals: { - AVAILABLE_STAGING_LOCALES: null, - }, - - snapshotFormat: { - escapeString: true, - printBasicPrototype: true, - }, - verbose: false, + // Use Node.js environment (not browser/jsdom) + testEnvironment: 'node', + + // CLI-specific setup (just jest-extended for matchers) + setupFilesAfterEnv: ['./src/profile-query-cli/tests/setup.ts'], + + // CLI operations can be slow (loading profiles, spawning processes) + testTimeout: 30000, + + // File extensions for CLI tests + moduleFileExtensions: ['ts', 'js'], + + // No need for asset mocks in CLI tests + // No transformIgnorePatterns needed - we don't use ESM-only deps here + }, + ], }; diff --git a/marker-support-plan.md b/marker-support-plan.md new file mode 100644 index 0000000000..2f68a0c6c5 --- /dev/null +++ b/marker-support-plan.md @@ -0,0 +1,816 @@ +# Marker Support Implementation Plan for `pq` CLI + +## Overview + +This document outlines the implementation plan for adding comprehensive marker support to the profile-query CLI (`pq`). Markers provide ~50% of profiling insight (Layout/Reflow, JavaScript names, IPC messages, GPU boundaries) and are a critical missing feature. + +## Background + +### Marker Data Model (from codebase analysis) + +**Core Types:** + +- `Marker`: `{ start: ms, end: ms | null, name: string, category: number, data: MarkerPayload | null, threadId: Tid | null }` +- `MarkerPayload`: Union of 30+ specific payload types (Network, GCMajor, FileIO, IPC, DOMEvent, etc.) +- `MarkerSchema`: Defines display rules (`tooltipLabel`, `tableLabel`, `chartLabel`, `fields[]`, `display[]`, `description`) + +**Web UI Views:** + +1. **Marker Chart**: Rows grouped by (category, name), shows rectangles for intervals or diamonds for instants + - Each marker has a `chartLabel` (can be templated from payload data) + - Markers filtered by `display: ['marker-chart']` in schema +2. **Marker Table**: One row per marker with columns: start, end, name, tableLabel + - `tableLabel` is templated from marker schema +3. **Tooltip/Sidebar**: Shows `tooltipLabel`, all field key-value pairs, type description, stack trace if available + +**Key Properties:** + +- Instant markers: `end === null` +- Interval markers: `end !== null` +- Markers can have stacks (cause backtraces) +- Markers can have rich structured data (via MarkerSchema fields) +- Tens of thousands of markers per thread is common + +## Problem Statement + +We cannot naively print all markers and their fields to the CLI because: + +1. Profiles often contain 10,000+ markers per thread +2. Each marker can have 5-15 fields with verbose values +3. This would overwhelm both the reader and the LLM context window +4. Different marker types need different presentation strategies + +## Design Principles + +1. **Aggregation First**: Show summaries, not raw data +2. **Progressive Disclosure**: Start with overview, allow drilling down +3. **Context-Aware Grouping**: Group markers intelligently based on their characteristics +4. **Actionable Insights**: Present data that helps diagnose performance issues +5. **Format Flexibility**: Support both human-readable and machine-parseable output + +## Implementation Phases + +### Phase 1: Basic Marker Listing (MVP) + +**Goal**: Show high-level marker distribution and basic statistics + +**Commands:** + +```bash +pq thread markers # List marker groups with counts +pq thread markers --summary # Show aggregate statistics +pq marker info # Show details for a specific marker +``` + +**`pq thread markers` output:** + +``` +Markers in thread t-93 (Renderer) — 14,523 markers + +By Type (top 15): + Reflow 4,234 markers (interval: min=0.12ms, avg=2.3ms, max=45.2ms) + DOMEvent 3,891 markers (interval: min=0.01ms, avg=0.5ms, max=12.1ms) + Styles 2,456 markers (interval: min=0.05ms, avg=1.2ms, max=8.7ms) + JavaScript 1,823 markers (instant) + Paint 892 markers (interval: min=0.3ms, avg=5.1ms, max=23.4ms) + Network 234 markers (interval: min=5.2ms, avg=234.5ms, max=2341.2ms) + GCSlice 156 markers (interval: min=0.8ms, avg=12.3ms, max=156.7ms) + IPC (IPCOut) 89 markers (interval: min=0.01ms, avg=2.1ms, max=45.2ms) + ... (7 more types) + +By Category: + Layout 6,892 markers (47.5%) + JavaScript 4,234 markers (29.1%) + Graphics 1,456 markers (10.0%) + ... (4 more categories) + +Rate Analysis (markers/second): + DOMEvent: 45.2 markers/sec (rate: min=0.5ms, avg=22.1ms, max=2341ms) + Reflow: 12.3 markers/sec (rate: min=1.2ms, avg=81.2ms, max=5234ms) + Styles: 8.9 markers/sec (rate: min=2.1ms, avg=112.4ms, max=8912ms) + +Use --type to filter, --details for per-marker info, or m- handles to inspect individual markers. +``` + +**`pq thread markers --summary` output:** + +``` +Marker Summary for thread t-93 (Renderer) + +Total markers: 14,523 +Time range: 2.145s - 15.891s (13.746s) +Marker types: 22 +Marker categories: 7 + +Instant markers: 2,891 (19.9%) +Interval markers: 11,632 (80.1%) + +Duration statistics (interval markers only): + Min: 0.01ms + Avg: 3.4ms + Median: 1.2ms + P95: 12.3ms + P99: 45.6ms + Max: 234.5ms + +Longest intervals: + m-1234: Paint - 234.5ms (7.234s - 7.469s) + m-5678: GCMajor - 156.7ms (10.123s - 10.280s) + m-3456: Reflow - 89.3ms (12.456s - 12.545s) + m-7890: Network (https://example.com/api) - 2341.2ms (3.234s - 5.575s) + m-2345: DOMEvent (click) - 45.2ms (8.123s - 8.168s) +``` + +**Implementation Tasks:** + +- [x] Create `formatters/marker-info.ts` for marker formatting logic +- [x] Implement `MarkerMap` class (similar to `ThreadMap`, `FunctionMap`) +- [x] Add marker aggregation functions (group by type, category, compute stats) +- [x] Add `ProfileQuerier.threadMarkers()` method +- [x] Add `ProfileQuerier.markerInfo(markerHandle)` method +- [x] Wire up in protocol.ts, daemon.ts, index.ts +- [x] Add unit tests for utility functions (formatDuration, computeDurationStats, computeRateStats) + +**Status: ✅ COMPLETE** (Commits: 25eaf637, 63e5d2d7) + +**Data Structures:** + +```typescript +// MarkerMap for handle management +class MarkerMap { + private markers: Marker[] = []; + private handleToIndex: Map = new Map(); + private indexToHandle: Map = new Map(); + + registerMarker(marker: Marker, index: MarkerIndex): string; + getMarker(handle: string): Marker | null; + getMarkers(): Marker[]; +} + +// Aggregation structures +interface MarkerTypeStats { + typeName: string; + count: number; + isInterval: boolean; + durationStats?: DurationStats; + rateStats?: RateStats; + topMarkers: Array<{ handle: string; label: string; duration?: number }>; +} + +interface DurationStats { + min: number; + max: number; + avg: number; + median: number; + p95: number; + p99: number; +} + +interface RateStats { + markersPerSecond: number; + minGap: number; + avgGap: number; + maxGap: number; +} +``` + +### Phase 2: Filtering and Search + +**Goal**: Allow users to filter markers by various criteria + +**Status: ✅ COMPLETE** (Commits: 1e478dcc, 43ccb20c) + +**What was implemented:** + +Phase 2 provides both text-based search and duration-based filtering: + +**Search filtering** leverages the existing marker search functionality built into the profiler codebase. This approach: + +- Reuses tested, proven code from the web UI +- Avoids duplicating complex filtering logic +- Provides a simple, flexible search interface + +**Duration filtering** is implemented by filtering marker indexes after search filtering: + +- Filters markers by minimum and/or maximum duration in milliseconds +- Excludes instant markers when duration constraints are specified +- Supports combination with search filtering + +**Commands:** + +```bash +pq thread markers --search DOMEvent # Search for "DOMEvent" markers +pq thread markers --search Stack # Search for markers with "Stack" in name +pq thread markers --category Graphics # Filter by Graphics category +pq thread markers --category GC # Partial match: matches "GC / CC" +pq thread markers --has-stack # Only markers with stack traces +pq thread markers --min-duration 10 # Markers with duration >= 10ms +pq thread markers --max-duration 100 # Markers with duration <= 100ms +pq thread markers --limit 1000 # Limit to first 1000 markers +pq thread markers --min-duration 5 --max-duration 50 # Markers between 5-50ms +pq thread markers --category Other --min-duration 10 # Combined category and duration +pq thread markers --has-stack --category Layout --min-duration 1 # All filters combined +pq thread markers --category Layout --limit 50 # Limit after filtering +pq thread markers --search Reflow --min-duration 5 # Combined search and duration +``` + +**Output example:** + +```bash +$ pq thread markers --search DOMEvent + +Markers in thread t-0 (Parent Process) — 2849 markers (filtered from 258060) + +By Type (top 15): + DOMEvent 2849 markers (interval: min=0.40μs, avg=14.53μs, max=2.25ms) + +By Category: + DOM 2849 markers (100.0%) + +Rate Analysis (markers/second): + DOMEvent: 93.7 markers/sec (rate: min=2.40μs, avg=10.68ms, max=3.37s) + +Use --search to filter markers, or m- handles to inspect individual markers. +``` + +**Implementation approach:** + +```typescript +// Use built-in marker search instead of custom filtering +if (searchString) { + store.dispatch(changeMarkersSearchString(searchString)); +} +const filteredIndexes = searchString + ? threadSelectors.getSearchFilteredMarkerIndexes(state) + : threadSelectors.getFullMarkerListIndexes(state); +// Always clear search after use +store.dispatch(changeMarkersSearchString('')); +``` + +**Completed Tasks:** + +- [x] Add `--search` parameter to CLI +- [x] Wire up `changeMarkersSearchString` action dispatch +- [x] Use `getSearchFilteredMarkerIndexes` selector for filtered results +- [x] Add try/finally block to ensure search string is cleared +- [x] Update protocol, daemon, and CLI to pass search string +- [x] Show "(filtered from N)" when filters are active +- [x] Update help text with search examples +- [x] Add `--min-duration` and `--max-duration` parameters to CLI +- [x] Implement duration filtering by whittling down filtered marker indexes +- [x] Add validation for duration parameter inputs +- [x] Update help text with duration filtering examples +- [x] Add `--category` parameter to CLI for filtering by category name +- [x] Implement case-insensitive substring matching for category filtering +- [x] Update help text with category filtering examples + +**Additional enhancements (implemented):** + +- [x] `--min-duration ` - Filter by minimum duration (Committed: 43ccb20c) +- [x] `--max-duration ` - Filter by maximum duration (Committed: 43ccb20c) +- [x] `--category ` - Filter by category name with case-insensitive substring matching (Committed: 7a44d4f9) +- [x] `--has-stack` - Only show markers with stack traces (Committed: bd52b911) +- [x] `--limit ` - Limit the number of markers used in aggregation (Committed: pending) + +Duration filtering is implemented by filtering the marker indexes after search filtering. Instant markers (markers with no end time) are excluded when duration constraints are specified. + +Category filtering uses case-insensitive substring matching on category names, allowing partial matches (e.g., "GC" matches "GC / CC"). + +Stack filtering checks for markers that have a `cause` field in their data payload, which contains stack trace information. + +Limit caps the number of markers used in aggregation after all other filters are applied. This is useful for quickly examining a subset of markers from large profiles. + +**Future enhancements (not yet implemented):** + +The following filtering options could be added in the future: + +- [ ] `--field :` - Filter by field values + +### Phase 3: Smart Grouping and Sub-grouping + +**Goal**: Handle markers with similar names but different characteristics + +**Status: ✅ COMPLETE** (Commits: 7c64ef07, ae17f140) + +**What was implemented:** + +Phase 3 provides multi-level marker grouping with both manual and automatic grouping strategies: + +**Custom grouping** via `--group-by` allows hierarchical grouping by any combination of: + +- `type`: Marker type (data.type) +- `name`: Marker name +- `category`: Category name +- `field:`: Any marker field (e.g., `field:eventType`, `field:phase`) + +**Auto-grouping** via `--auto-group` uses a smart heuristic to automatically select the best field for sub-grouping: + +- Prefers fields with 3-20 unique values (ideal grouping range) +- Avoids fields with too many unique values (>50, likely IDs or timestamps) +- Requires fields to appear in >80% of markers +- Boosts score for fields that appear in all markers + +**Commands:** + +```bash +pq thread markers --group-by type,name # Group by type, then name +pq thread markers --group-by type,field:eventType # Group by type, then eventType field +pq thread markers --group-by category,type # Group by category, then type +pq thread markers --auto-group # Automatic smart grouping +pq thread markers --search DOMEvent --group-by field:eventType # Filter + custom grouping +``` + +**Output example:** + +```bash +$ pq thread markers --search DOMEvent --auto-group --limit 200 + +Markers in thread t-0 (Parent Process) — 200 markers (filtered from 258060) + +By Type (top 15): + DOMEvent 200 markers (interval: min=0.60μs, avg=41.65μs, max=2.06ms) + pointermove 59 markers (interval: min=1.30μs, avg=2.82μs, max=4.50μs) + mousemove 59 markers (interval: min=16.00μs, avg=27.55μs, max=84.30μs) + MozAfterPaint 16 markers (interval: min=1.30μs, avg=4.41μs, max=8.50μs) + mouseenter 15 markers (interval: min=0.60μs, avg=1.07μs, max=3.20μs) + mouseleave 13 markers (interval: min=0.60μs, avg=1.25μs, max=3.10μs) + ... +``` + +**Completed Tasks:** + +- [x] Implement multi-level grouping (group by multiple keys) +- [x] Add auto-grouping heuristic (analyze marker field variance with smart scoring) +- [x] Add `--group-by` flag with support for `type`, `name`, `category`, `field:` +- [x] Implement sub-group statistics (per-group duration/rate stats) +- [x] Add hierarchical display with proper indentation +- [x] Limit recursive depth to 3 levels to prevent excessive nesting + +### Phase 4: Marker Details and Field Display + +**Goal**: Show detailed information for individual markers + +**Status: ✅ COMPLETE** (Commits: 77c95d9d, d9817b0d) + +**What was implemented:** + +Phase 4 provides comprehensive marker inspection with detailed field display and complete stack trace viewing. Marker handles are now visible in marker listings, making it easy to inspect specific markers. + +**Commands:** + +```bash +pq marker info # Full marker details with stack preview +pq marker stack # Complete stack trace (all frames) +``` + +**Actual output:** + +```bash +$ pq thread markers --has-stack --limit 3 + +Markers in thread t-0 (Parent Process) — 3 markers (filtered from 258060) + +By Type (top 15): + TextStack 1 markers (interval: min=651.20μs, avg=651.20μs, max=651.20μs) + Examples: m-1 (651.20μs) + Text 1 markers (interval: min=1.93ms, avg=1.93ms, max=1.93ms) + Examples: m-2 (1.93ms) + FlowMarker 1 markers (instant) + Examples: m-3 + +$ pq marker info m-1 + +Marker m-1: NotifyObservers - NotifyObservers + +Type: TextStack +Category: Other +Time: 1h2m - 1h2m (651.20μs) +Thread: t-0 (Parent Process) + +Fields: + Details: profiler-started + +Stack trace: + Captured at: 1h2m + [1] xul.dll!NotifyObservers(char const*, nsISupports*) + [2] xul.dll!NotifyProfilerStarted(mozilla::PowerOfTwo const&, mozilla::Maybe) + [3] xul.dll!profiler_start(mozilla::PowerOfTwo) + ... + [20] xul.dll!js::InternalCallOrConstruct(JSContext*, JS::CallArgs const&, js::) + ... (101 more frames) + +Use 'pq marker stack m-1' for the full stack trace. + +$ pq marker stack m-1 + +Stack trace for marker m-1: NotifyObservers + +Thread: t-0 (Parent Process) +Captured at: 1h2m + + [1] xul.dll!NotifyObservers(char const*, nsISupports*) + [2] xul.dll!NotifyProfilerStarted(mozilla::PowerOfTwo const&, mozilla::Maybe) + ... + [120] ntdll.dll!RtlUserThreadStart + [121] (root) +``` + +**Implementation details:** + +- **Marker handles visible**: Top 3 example markers shown for each type with handles and durations +- **`pq marker info`**: Shows full marker details with stack trace preview (first 20 frames) +- **`pq marker stack`**: Displays complete stack traces without frame limit +- **Stack formatting**: Reuses formatFunctionNameWithLibrary() for consistent display with library names +- **MarkerSchema integration**: Fields formatted using existing MarkerSchema formatters from web UI + +**Implementation Tasks:** + +- [x] Implement `markerInfo(handle)` method +- [x] Format marker fields using MarkerSchema formatters +- [x] Add stack trace formatting (walks stack table, formats with library names) +- [x] Implement `markerStack(handle)` method for full stack traces +- [x] Display marker handles in listings (top 3 examples per type) +- [x] Wire up in protocol.ts, daemon.ts, index.ts +- [x] Update CLI help text and examples + +**Future enhancements (not yet implemented):** + +- [ ] `pq marker expand ` - Show full field values for truncated fields +- [ ] `--format json` option for machine-readable output + +### Phase 5: Temporal Visualization (ASCII Charts) + +**Goal**: Provide a compact visual representation of marker timing + +**Commands:** + +```bash +pq thread markers --timeline # ASCII timeline +pq thread markers --type Reflow --timeline # Timeline for specific type +pq thread markers --histogram # Duration histogram +``` + +**Output example:** + +```bash +$ pq thread markers --type Reflow --timeline + +Reflow markers timeline (thread t-93, 2.145s - 15.891s, 13.746s total) + +Duration histogram (4,234 markers): + 0-1ms ████████████████████████████████████████ 1,892 (44.7%) + 1-2ms ████████████████████ 956 (22.6%) + 2-5ms ████████████ 587 (13.9%) + 5-10ms ██████ 324 (7.7%) + 10-20ms ███ 234 (5.5%) + 20-50ms ██ 189 (4.5%) + 50-100ms █ 45 (1.1%) + 100ms+ █ 7 (0.2%) + +Timeline (each char = 137ms, | = marker): +2.1s | | || | | | | || | | | | | 3.5s +3.5s | | | || ||| | | | | || | | | | 4.9s +4.9s | ||| | | | || | | | || | | | | 6.3s +6.3s | | | ||| | | | | | | | | | | | | 7.7s +7.7s | || | | | | || | | | || | | | | 9.1s +9.1s | | | || | | | | | | || | | | | | 10.5s +10.5s | | | | || | | || | | | | || | | | 11.9s +11.9s | | | || | | | | | | || | | | | | 13.3s +13.3s | | | | | || | | | || | | | | | | | | 14.7s +14.7s | || | | | | | || | | | | | | | | | 15.9s + +Density over time (markers per second): + 2-4s: 12.3/s ████████ + 4-6s: 18.7/s ████████████ + 6-8s: 8.9/s ██████ + 8-10s: 23.4/s ███████████████ + 10-12s: 15.6/s ██████████ + 12-14s: 11.2/s ███████ + 14-16s: 6.7/s ████ + +Peak activity: 8.123s - 8.456s (23 markers in 333ms window) +``` + +**Implementation Tasks:** + +- [ ] Implement ASCII timeline generator +- [ ] Implement duration histogram generator +- [ ] Add density analysis (markers per time bucket) +- [ ] Make timeline resolution configurable (auto-adjust to terminal width) +- [ ] Add `--width` flag to control chart width + +### Phase 6: Advanced Analysis Features + +**Goal**: Provide deeper insights into marker patterns + +**Commands:** + +```bash +pq thread markers --rate-analysis # Analyze marker rate patterns +pq thread markers --type Network --waterfall # Network waterfall chart +pq thread markers --overlap-analysis # Find overlapping markers +pq thread markers --critical-path # Identify critical path markers +``` + +**Rate Analysis Output:** + +```bash +$ pq thread markers --type DOMEvent --rate-analysis + +Rate analysis for DOMEvent markers (thread t-93) + +Overall rate: 45.2 markers/sec + +Inter-marker gaps (time between successive markers): + Min: 0.5ms + Avg: 22.1ms + Median: 18.7ms + P95: 89.3ms + P99: 234.5ms + Max: 2341.2ms + +Burst detection (3+ markers within 50ms): + Burst at 8.123s: 5 markers in 23ms (click cascade) + Burst at 10.234s: 8 markers in 45ms (scroll events) + Burst at 12.456s: 4 markers in 31ms (mousemove cluster) + ... (12 more bursts) + +Idle periods (>1000ms without markers): + 1.234s - 2.567s (1333ms) + 5.678s - 7.123s (1445ms) + 13.890s - 15.234s (1344ms) +``` + +**Network Waterfall Output:** + +```bash +$ pq thread markers --type Network --waterfall + +Network waterfall (thread t-93, 50 requests) + +Time Duration Status URL +2.145s ████████ 200 https://example.com/api/users +2.234s ██ 200 https://cdn.example.com/logo.png +2.267s ███ 200 https://cdn.example.com/style.css +2.289s ████ 200 https://cdn.example.com/app.js +2.345s ████████████ 200 https://api.example.com/data?page=1 +2.456s ██ 304 https://cdn.example.com/font.woff2 +... + +Legend: + ████ = Request (DNS + Connect + Request + Response) + Each █ = ~50ms +``` + +**Implementation Tasks:** + +- [ ] Implement rate analysis (gap statistics, burst detection) +- [ ] Implement overlap detection (find concurrent markers) +- [ ] Add network waterfall visualization +- [ ] Add critical path analysis (longest marker chains) +- [ ] Add `--export` flag to save analysis to JSON/CSV + +## Technical Implementation Details + +### Component Structure + +``` +src/profile-query/ +├── index.ts # ProfileQuerier class +├── formatters/ +│ ├── marker-info.ts # Marker listing/summary formatters +│ ├── marker-details.ts # Individual marker detail formatter +│ ├── marker-timeline.ts # ASCII timeline/histogram generators +│ └── marker-analysis.ts # Advanced analysis formatters +├── marker-map.ts # MarkerMap handle manager +├── marker-aggregator.ts # Marker aggregation logic +├── marker-filter.ts # Marker filtering logic +└── marker-grouping.ts # Smart grouping heuristics +``` + +### Key Algorithms + +**1. Marker Aggregation** + +```typescript +function aggregateMarkersByType( + markers: Marker[], + markerSchemaByName: MarkerSchemaByName +): MarkerTypeStats[] { + const groups = new Map(); + + for (const marker of markers) { + const type = marker.data?.type ?? 'Unknown'; + if (!groups.has(type)) { + groups.set(type, []); + } + groups.get(type)!.push(marker); + } + + return Array.from(groups.entries()).map(([type, markers]) => ({ + typeName: type, + count: markers.length, + isInterval: markers[0].end !== null, + durationStats: computeDurationStats(markers), + rateStats: computeRateStats(markers), + topMarkers: selectTopMarkers(markers, 5), + })); +} +``` + +**2. Duration Statistics** + +```typescript +function computeDurationStats(markers: Marker[]): DurationStats | undefined { + const durations = markers + .filter((m) => m.end !== null) + .map((m) => m.end! - m.start) + .sort((a, b) => a - b); + + if (durations.length === 0) return undefined; + + return { + min: durations[0], + max: durations[durations.length - 1], + avg: durations.reduce((a, b) => a + b, 0) / durations.length, + median: durations[Math.floor(durations.length / 2)], + p95: durations[Math.floor(durations.length * 0.95)], + p99: durations[Math.floor(durations.length * 0.99)], + }; +} +``` + +**3. Rate Statistics** + +```typescript +function computeRateStats(markers: Marker[]): RateStats { + const sorted = [...markers].sort((a, b) => a.start - b.start); + const gaps: number[] = []; + + for (let i = 1; i < sorted.length; i++) { + gaps.push(sorted[i].start - sorted[i - 1].start); + } + + const timeRange = sorted[sorted.length - 1].start - sorted[0].start; + const markersPerSecond = (markers.length / timeRange) * 1000; + + return { + markersPerSecond, + minGap: Math.min(...gaps), + avgGap: gaps.reduce((a, b) => a + b, 0) / gaps.length, + maxGap: Math.max(...gaps), + }; +} +``` + +**4. Smart Grouping** + +```typescript +function autoGroupMarkers( + markers: Marker[], + schema: MarkerSchema +): GroupingStrategy { + // Analyze variance in marker fields + const fieldVariance = analyzeFieldVariance(markers, schema); + + // If a field has high variance (e.g., eventType in DOMEvent markers), + // use it as a grouping key + const highVarianceFields = fieldVariance + .filter((f) => f.uniqueRatio > 0.3) // >30% unique values + .sort((a, b) => b.uniqueRatio - a.uniqueRatio); + + if (highVarianceFields.length > 0) { + return { type: 'field', field: highVarianceFields[0].key }; + } + + // Fall back to type-level grouping + return { type: 'type' }; +} +``` + +### Protocol Updates + +**`protocol.ts`:** + +```typescript +export type ClientCommand = + | { command: 'thread'; subcommand: 'markers'; options?: MarkerListOptions } + | { command: 'marker'; subcommand: 'info'; marker: string } + | { command: 'marker'; subcommand: 'stack'; marker: string } + | { command: 'marker'; subcommand: 'expand'; marker: string; field: string }; +// ... existing commands + +interface MarkerListOptions { + type?: string; // Filter by type + category?: string; // Filter by category + minDuration?: number; // Min duration in ms + maxDuration?: number; // Max duration in ms + nameFilter?: string; // Regex for name + fieldFilter?: string; // Format: "field:value" + hasStack?: boolean; // Only markers with stacks + groupBy?: string; // Grouping strategy + timeline?: boolean; // Show ASCII timeline + histogram?: boolean; // Show duration histogram + summary?: boolean; // Show summary only + limit?: number; // Limit output lines + format?: 'text' | 'json'; // Output format +} +``` + +## Testing Strategy + +1. **Unit Tests** (`src/test/unit/profile-query-markers.test.ts`): + - Test marker aggregation functions + - Test filtering logic + - Test statistics calculations + - Test ASCII chart generation + +2. **Integration Tests** (`yarn test-cli`): + - Test marker listing with real profiles + - Test filtering combinations + - Test grouping strategies + - Test marker detail display + +3. **Manual Testing**: + - Test with large profiles (10k+ markers) + - Test with different marker types + - Test edge cases (no markers, single marker, all instant/interval) + +## Performance Considerations + +1. **Lazy Marker Loading**: Don't load all marker details unless needed +2. **Pagination**: For large result sets, support pagination (e.g., `--page 2 --page-size 50`) +3. **Streaming Output**: For very large listings, stream output instead of buffering +4. **Caching**: Cache aggregated stats in ProfileQuerier for repeated queries +5. **Sampling**: For >10k markers, consider sampling for histogram/timeline + +## Open Questions / Design Decisions + +1. **Handle Persistence**: Should marker handles (m-N) be stable across sessions, or ephemeral? + - **Decision**: Ephemeral within session (like function handles), reset on each `pq load` + +2. **Default Grouping**: What should be the default grouping strategy? + - **Decision**: Group by type first, with option to drill down + +3. **Timeline Resolution**: How to auto-adjust timeline character width? + - **Decision**: Divide time range by terminal width (default 80 chars), cap at 1 char = 10ms minimum + +4. **Field Display**: Should we show all fields by default or only non-hidden fields? + - **Decision**: Follow marker schema `hidden` flag, add `--all-fields` to override + +5. **Stack Traces**: Should stacks be shown inline or require separate command? + - **Decision**: Show truncated stack (top 5 frames) in `marker info`, full stack in `marker stack` + +6. **JSON Output**: What should the JSON schema look like? + - **Decision**: Match web UI's marker structure, include all computed stats + +## Success Metrics + +1. Can view marker distribution across a thread in <5 seconds +2. Can identify performance bottlenecks (long markers) in <3 commands +3. Can filter to specific marker types/categories in 1 command +4. Can inspect individual marker details including stack traces +5. Output is readable and actionable for performance analysis + +## Future Enhancements (Post-Launch) + +1. **Marker Comparison**: Compare marker patterns between two time ranges +2. **Marker Correlation**: Find correlations between different marker types +3. **Marker Export**: Export filtered markers to flamegraph format +4. **Marker Diff**: Compare markers between two profiles +5. **Smart Filters**: Pre-defined filters for common analysis tasks (e.g., "long-layout", "slow-network") +6. **Interactive Mode**: TUI for browsing markers interactively + +## Implementation Priority + +**Must Have (Phase 1 & 2):** + +- Basic marker listing with aggregation +- Marker type/category filtering +- Individual marker details +- Duration statistics + +**Should Have (Phase 3 & 4):** + +- Smart grouping by fields +- Marker stack traces +- Field-based filtering + +**Nice to Have (Phase 5 & 6):** + +- ASCII timeline/histogram +- Rate analysis +- Network waterfall +- Critical path analysis + +## Timeline Estimate + +- Phase 1 (Basic Listing): 2-3 days +- Phase 2 (Filtering): 1-2 days +- Phase 3 (Grouping): 1-2 days +- Phase 4 (Details): 1-2 days +- Phase 5 (Visualization): 2-3 days +- Phase 6 (Advanced): 2-3 days + +**Total**: ~2 weeks for full implementation +**MVP (Phases 1-2)**: ~1 week diff --git a/package.json b/package.json index e091457a9c..a2ab1b6889 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ "build-symbolicator-cli": "cross-env NODE_ENV=production node scripts/build-symbolicator.mjs", "build-prod:quiet": "yarn build-prod", "build-symbolicator-cli:quiet": "yarn build-symbolicator-cli", + "build-profile-query": "cross-env NODE_ENV=production node scripts/build-profile-query.mjs", + "build-profile-query:quiet": "yarn build-profile-query", + "build-profile-query-cli": "cross-env NODE_ENV=production node scripts/build-profile-query-cli.mjs", "lint": "node bin/output-fixing-commands.js run-p lint-js lint-css prettier-run", "lint-fix": "run-p lint-fix-js lint-fix-css prettier-fix", "lint-js": "node bin/output-fixing-commands.js eslint . --report-unused-disable-directives --cache --cache-strategy content", @@ -43,15 +46,16 @@ "start-examples": "ws -d examples/ -s index.html -p 4244", "start-docs": "ws -d docs-user/ -p 3000", "start-photon": "node scripts/run-photon-dev-server.mjs", - "test": "node bin/output-fixing-commands.js cross-env LC_ALL=C TZ=UTC NODE_ENV=test jest", + "test": "node bin/output-fixing-commands.js cross-env LC_ALL=C TZ=UTC NODE_ENV=test jest --selectProjects=browser", "test-node": "node bin/output-fixing-commands.js cross-env LC_ALL=C TZ=UTC NODE_ENV=test JEST_ENVIRONMENT=node jest", - "test-all": "run-p --max-parallel 4 ts license-check lint test test-alex test-lockfile", + "test-all": "run-p --max-parallel 4 ts license-check lint test test-alex test-lockfile && yarn test-cli", "test-build-coverage": "yarn test --coverage --coverageReporters=html", "test-serve-coverage": "ws -d coverage/ -p 4343", "test-coverage": "run-s test-build-coverage test-serve-coverage", "test-alex": "alex ./docs-* CODE_OF_CONDUCT.md CONTRIBUTING.md README.md", "test-lockfile": "lockfile-lint --path yarn.lock --allowed-hosts yarn --validate-https", "test-debug": "cross-env LC_ALL=C TZ=UTC NODE_ENV=test node --inspect-brk node_modules/.bin/jest --runInBand", + "test:cli": "yarn build-profile-query-cli && jest --selectProjects=cli", "postinstall": "patch-package" }, "license": "MPL-2.0", diff --git a/pq-case-study-2.md b/pq-case-study-2.md new file mode 100644 index 0000000000..d0055461c8 --- /dev/null +++ b/pq-case-study-2.md @@ -0,0 +1,527 @@ +# pq Case Study 2: Investigating Repeated Rendering Spikes in Firefox + +**Profile:** https://share.firefox.dev/4oLEjCw +**Date:** November 4, 2025 +**Investigator:** Claude (via pq CLI) + +## Executive Summary + +Using pq, I investigated a Firefox performance profile showing repeated GPU rendering spikes. The investigation revealed that the GPU Renderer thread was spending ~27% of spike time in Present operations (DirectComposition/DXGI), triggered by a loop of WM_PAINT messages on the main thread. The main thread would trigger rendering work, wait for the GPU (FlushRendering), and repeat. + +## Investigation Process + +### Initial Exploration + +```bash +pq load 'https://share.firefox.dev/4oLEjCw' +pq profile info +``` + +**Observation:** The profile overview immediately showed the GPU process (p-14) consuming 16.1s of CPU, with the Renderer thread (t-93) at 7.9s being the hottest thread. Multiple CPU spike periods were visible at 160% (2 cores). + +### Deep Dive into GPU Thread + +```bash +pq thread select t-93 +pq thread samples +``` + +**Problem:** The output was **extremely verbose** - over 2000 lines for the full profile view. While comprehensive, it required significant scrolling and cognitive effort to digest. The top functions list showed 50 entries before truncating 2224 more. + +**Finding:** In the full profile: + +- 63.5% of time: Thread idle/waiting +- 36.5% of time: Active rendering work +- 16.4% of active time: DCSwapChain::Present operations +- 20.4% of active time: composite_simple + +### Zooming into Spike Periods + +```bash +pq view push ts-6,ts-7 +pq thread samples | head -n 100 +``` + +**Positive Experience:** After zooming into a specific spike period (391ms), the output became **much more manageable** - only 179 samples vs 14,466 for the full profile. The percentages shifted dramatically: + +- 42.5% idle (down from 63.5%) +- 57.5% in UpdateAndRender +- 27.4% in Present operations + +This focused view made it easy to see that during spikes, the thread was spending proportionally more time presenting frames. + +```bash +pq status +pq view pop +``` + +**Positive Experience:** The `status` command clearly showed my current context (selected thread and view range). `view pop` cleanly restored the previous view. + +### Investigating the Trigger + +```bash +pq thread select t-0 +pq thread samples | head -n 80 +``` + +**Finding:** The main thread (GeckoMain) was: + +- 43% idle (waiting for GPU) +- 77% of active time in OnPaint → ProcessPendingUpdates +- Waiting in PCompositorBridge::Msg_FlushRendering + +**Root cause:** A loop of WM_PAINT messages triggering repeated rendering work, with the main thread blocking on GPU completion before proceeding. + +## What Worked Well + +### 1. **Progressive Exploration Model** + +The workflow of `profile info` → `thread select` → `thread samples` → `view push` → drill down worked naturally. Each command provided the context needed for the next step. + +### 2. **Thread Handle System** + +Thread handles like `t-93`, `t-0` were **concise and memorable**. Once I saw "t-93 (Renderer)" in the profile overview, I could directly select it without searching. + +### 3. **Time Range Navigation** + +- **Timestamp names** (ts-6, ts-7, etc.) made it trivial to zoom into spike periods identified in the overview +- **View range stack** (`push`/`pop`) allowed easy exploration without losing context +- `status` command provided clear confirmation of current state + +### 4. **Profile Info Overview** + +The hierarchical CPU activity breakdown was excellent: + +``` +- 81% for 30409.5ms (1865812 samples): [ts-1,ts-z] + - 160% for 390.6ms (27322 samples): [ts-6,ts-7] + - 160% for 255.3ms (18215 samples): [ts-8,ts-9] +``` + +This immediately highlighted where to investigate, with ready-to-use timestamp ranges. + +### 5. **Consistent Command Structure** + +Commands followed predictable patterns: + +- `pq ` (e.g., `thread select`, `view push`) +- Optional flags for refinement (`--thread t-0`) +- Clear, descriptive output + +## What Didn't Work Well + +### 1. **Overwhelming Verbosity in Wide Views** ⚠️ + +**Problem:** `thread samples` output for the full profile was **2000+ lines**. This is cognitively exhausting in a terminal. + +**Impact:** + +- Hard to find actionable information quickly +- Need to pipe through `head` or scroll extensively +- Function list shows "50 entries" but mentions "2224 more omitted" - makes it unclear if I'm missing something important + +**Suggestion:** Add a `--limit N` flag to truncate output: + +```bash +pq thread samples --limit 20 # Show only top 20 functions +``` + +Or make the default output more concise (e.g., top 15-20 functions only, with an explicit "use --verbose for full output" message). + +### 2. **No Function Search/Filter** ❌ + +**Problem:** Once I saw the profile overview, I wanted to search for specific functions (e.g., "how much time in Present?"). Currently, I have to: + +1. Run `thread samples` (2000+ lines) +2. Manually search through output or pipe to `grep` +3. Parse percentages manually + +**Suggestion:** Add function search/filter: + +```bash +pq thread search "Present" +pq thread functions --filter "atidxx64" # Show only AMD driver functions +pq function info "DCSwapChain::Present" # Details about a specific function +``` + +### 3. **Call Tree Format is Hard to Parse** + +**Problem:** The ASCII tree is deeply nested and uses UTF-8 box characters: + +``` +└─ └─ └─ └─ └─ └─ └─ └─ └─ └─ └─ └─ └─ └─ └─ ├─ └─ ├─ ... +``` + +After 10+ levels of nesting, it's **visually overwhelming** and hard to follow lineage. + +**Impact:** + +- Difficult to trace execution paths +- Hard to identify "where am I in the stack?" +- The "... (N more children)" truncation breaks flow + +**Suggestion:** + +- Limit tree depth display (show top 5-10 levels by default) +- Add indentation-based format option: + ``` + RenderThread::UpdateAndRender [57.5%] + RendererOGL::UpdateAndRender [54.2%] + wr_renderer_render [48.6%] + Renderer::render [48.6%] + Renderer::draw_frame [43.0%] + composite_frame [35.2%] + composite_simple [35.2%] + PresentImpl [27.4%] + ``` +- Add a `--tree-depth N` flag + +### 4. **No Comparison Between Time Ranges** ❌ + +**Problem:** I identified a spike period (ts-6 to ts-7) where Present was 27.4% of time, vs 16.4% in the full profile. But I had to **manually compare** by running commands twice and noting differences. + +**Suggestion:** Add range comparison: + +```bash +pq view compare ts-6,ts-7 vs ts-8,ts-9 +# Shows side-by-side differences in top functions +``` + +### 5. **No Markers/Events View** ❌ + +**Problem:** The thread info showed "297515 markers" for the main thread, but there's **no way to view them**. Markers often provide critical context (e.g., "Reflow", "Styles", "JavaScript" markers). + +**Suggestion:** Implement marker commands: + +```bash +pq thread markers # List recent markers +pq thread markers --type Reflow # Filter by type +pq marker info # Marker details +``` + +### 6. **Missing Symbol Information is Opaque** 🔶 + +**Problem:** AMD GPU driver functions appear as: + +``` +atidxx64.dll!fun_3e8f0 - total: 2354 (16.3%) +atidxx64.dll!fun_a56960 - self: 598 (4.1%) +``` + +These are **meaningless** for diagnosis. While it's expected that third-party binaries lack symbols, pq provides **no indication** that: + +- These are unsymbolicated +- What type of component this is (GPU driver) +- Whether symbolication was attempted + +**Impact:** Users may think these are real function names rather than placeholder addresses. + +**Suggestion:** + +- Clearly mark unsymbolicated functions: `atidxx64.dll!` +- Group by module in output: "AMD GPU Driver (unsymbolicated): 25% total" +- Add metadata about module types (system library, GPU driver, etc.) + +### 7. **No Aggregated "Waiting Time" View** ⚠️ + +**Problem:** I saw 63.5% of GPU thread time was in `ZwWaitForAlertByThreadId` (waiting), but there's no easy way to see: + +- What the thread is waiting _for_ +- All waiting periods aggregated +- Patterns in wait times + +**Suggestion:** + +```bash +pq thread waits # Show all wait operations +pq thread waits --min-duration 10ms # Filter significant waits +``` + +### 8. **No "Heaviest Stack" or Sample View** ❌ + +**Problem:** The profiler UI shows "heaviest stack" (the single most expensive call stack). This is often the smoking gun. pq only shows aggregated functions and trees. + +**Suggestion:** + +```bash +pq thread stacks # Show heaviest individual stacks +pq thread stacks --limit 5 # Top 5 heaviest +pq sample info # Details about a specific sample +``` + +## Cognitive Load Assessment + +### Low Cognitive Load ✓ + +- **Progressive disclosure:** Start with overview, drill down as needed +- **Consistent patterns:** Commands are predictable +- **Clear state:** `status` always shows where you are +- **Good naming:** Thread handles (t-93) and timestamp names (ts-6) are intuitive + +### High Cognitive Load ⚠️ + +- **Output volume:** Full profile views are overwhelming (2000+ lines) +- **Manual correlation:** Must compare outputs mentally or with external tools +- **Tree parsing:** Deep call stacks are hard to follow +- **Missing context:** No markers, no sample-level view, no wait analysis + +### Recommendations + +1. **Default to concise output** (top 15-20 items), with `--verbose` for full details +2. **Add summary statistics** at the end of output (e.g., "Top 3 functions account for 45% of time") +3. **Implement filtering** to reduce noise (by function name, module, threshold) +4. **Add comparison commands** to reduce mental arithmetic + +## Output Quality + +### What's Good ✓ + +- **Percentages are clear:** Both absolute (time) and relative (%) shown +- **Hierarchical structure:** Process → Thread → Function breakdowns are logical +- **Time formatting:** Milliseconds for short durations, seconds for long +- **Sample counts:** Shown alongside time, helpful for confidence + +### What's Missing ⚠️ + +- **Context indicators:** No indication when symbols are missing +- **Noise filtering:** Low-impact functions (< 1%) dominate output +- **Actionable guidance:** Output doesn't suggest next steps (e.g., "Focus on these 3 hot functions") +- **Visual hierarchy:** Everything has equal weight in plain text + +### What's Excessive 🔶 + +- **Boilerplate call stacks:** Lines 1-15 of every stack are always the same (RtlUserThreadStart → BaseThreadInitThunk → ...) +- **Truncated function names:** Some C++ template names are cut off mid-word (e.g., `mozilla::interceptor::FuncHook # Obvious +pq profile info # Logical +pq thread select t-93 # Clear +pq thread samples # Descriptive +pq view push ts-6,ts-7 # Intuitive +pq status # Expected +``` + +### Awkward Commands ⚠️ + +- **Piping to head:** `pq thread samples | head -n 100` - shouldn't need shell plumbing for basic limiting +- **Filtering not built-in:** Must use `grep` externally +- **No inline thread selection:** `pq thread samples --thread t-93` doesn't work, must select first + +### Missing Commands ❌ + +```bash +pq thread markers # Not implemented +pq thread waits # Not implemented +pq thread stacks # Not implemented +pq function info # Not implemented +pq view compare # Not implemented +pq thread functions # Not implemented (list top functions only, no tree) +``` + +## Handling of Missing Symbols + +The profile includes AMD GPU driver code (`atidxx64.dll`) with no symbols. pq handled this **functionally** but **poorly for UX**: + +### What Works ✓ + +- Functions are assigned placeholder names (fun_3e8f0) +- Percentages are calculated correctly +- Call stacks show the unsymbolicated frames +- Module name (atidxx64.dll) is preserved + +### What's Broken 🔶 + +- **No indication these are unsymbolicated** - looks like real function names +- **No module-level grouping** - can't easily see "25% in AMD driver" +- **No hints about why** - is this expected? Is symbolication available? +- **Addresses are obfuscated** - fun_3e8f0 doesn't show the actual address (0x3e8f0) + +### Impact on Investigation + +Despite missing symbols, I could still: + +- ✓ Identify that GPU driver code was hot (atidxx64.dll functions in top list) +- ✓ See that it was called from D3D11/DXGI Present operations +- ✓ Quantify the time spent (27% in spike periods) + +But I couldn't: + +- ❌ Understand _what_ the driver was doing (memory allocation? rendering? waiting?) +- ❌ Distinguish different driver functions (fun_3e8f0 vs fun_a56960 - which is which?) +- ❌ Know if this is normal or indicates a problem + +### Recommendation + +``` +atidxx64.dll!<0x3e8f0> [unsymbolicated] - total: 2354 (16.3%) + Note: AMD GPU Driver - symbols unavailable + +Or group in output: + GPU Driver Activity (unsymbolicated): 25.4% total + atidxx64.dll!<0x3e8f0>: 16.3% + atidxx64.dll!<0xa56960>: 4.1% + atidxx64.dll!<0xa48860>: 1.6% +``` + +## Performance Profile Summary + +### The Problem + +Firefox was experiencing repeated CPU spikes (160% = 2 cores) every few hundred milliseconds, lasting 200-400ms each. + +### Root Cause + +1. **Main thread:** Continuous WM_PAINT message loop +2. **Main thread:** Triggers rendering via OnPaint → ProcessPendingUpdates +3. **Main thread:** Blocks waiting for GPU (PCompositorBridge::Msg_FlushRendering) +4. **GPU Renderer thread:** Processes frame rendering (WebRender) +5. **GPU Renderer thread:** 27% of spike time spent in DirectComposition Present operations +6. **Repeat:** Pattern repeats every ~300ms + +### Bottleneck + +The GPU Present path (DirectComposition → DXGI → AMD driver) is the bottleneck during spikes. The main thread is waiting for these Present operations to complete before continuing. + +### Likely Issue + +Either: + +- **VSync blocking:** Waiting for monitor refresh before presenting +- **GPU saturation:** AMD driver queueing work faster than GPU can execute +- **Desktop Window Manager contention:** Windows DWM compositing is slow + +## Overall Assessment + +### pq Strengths 💪 + +1. **Progressive exploration** model is natural and effective +2. **Time range navigation** (timestamps + view stack) is excellent +3. **Thread selection** with handles is simple and memorable +4. **Profile overview** immediately surfaces hot spots +5. **Consistent command structure** reduces learning curve + +### pq Weaknesses 😓 + +1. **Output verbosity** makes wide-scope views painful +2. **No filtering or search** forces manual grepping +3. **Missing features:** No markers, no waits, no stacks, no comparison +4. **Poor symbol UX:** Unsymbolicated code looks like real function names +5. **Call tree format** is hard to parse at depth + +### Would I Use pq for Real Investigations? + +**Yes, but with caveats:** + +**For quick triage:** ✓ Excellent - `profile info` + `thread select` + targeted `view push` works great + +**For deep investigation:** ⚠️ Frustrating - need to: + +- Pipe through `head` constantly to manage output +- Keep the profiler UI open for markers, stacks, and visual navigation +- Manually grep for function names +- Copy/paste outputs for comparison + +**pq is currently a "first-look tool"** - great for initial exploration, but you'll switch to the profiler UI for serious debugging. + +## Priority Improvements + +### P0 (Critical for Real Use) + +1. **Add `--limit` flag** to all commands that generate lists +2. **Implement marker viewing** (thread markers is wired up but not functional) +3. **Add function search/filter** (`pq thread functions --filter "Present"`) + +### P1 (High Value) + +4. **Improve call tree display** (limit depth, better formatting) +5. **Mark unsymbolicated functions clearly** +6. **Add module-level grouping** for unsymbolicated code + +### P2 (Nice to Have) + +7. **Add stack/sample viewing** (heaviest stacks) +8. **Add wait analysis** (thread waits) +9. **Add comparison** (view compare) +10. **Add inline thread selection** (`--thread` flag on all commands) + +## Comparison with Case Study 1 + +Both case studies investigated **the same profile** (https://share.firefox.dev/4oLEjCw) and reached remarkably **consistent conclusions**, validating the findings: + +### Identical Core Issues ✓ + +1. **Missing library/module context** - Both flagged this as the #1 critical problem +2. **Excessive output truncation** - Call trees, function lists, heaviest stacks all cut off too early +3. **Output verbosity** - Full profile views are overwhelming +4. **Missing marker support** - Identified as a major gap +5. **Same performance diagnosis** - Both found GPU rendering with repeated Present operations + +### Converging Recommendations ✓ + +Both case studies independently proposed: + +- **Time range format flexibility** - Support seconds (2.7,3.1) not just timestamp names +- **Function search/filtering** - Need to find specific functions +- **Deeper output limits** - Show more functions, more tree depth, more frames +- **Status command** - Show current context (thread, range, session) +- **Separate sample commands** - Split `thread samples` into focused views + +### Key Disagreements 🤔 + +**Function Handles:** + +- **Case Study 1 proposed:** Function handles like `f-234` for brevity +- **Case Study 2 (this):** Rejected handles as cognitive overhead; prefer smart truncation + +**Analysis:** I agree with Case Study 2 (my own conclusion). Function handles add indirection ("what was f-234 again?") and break copy/paste workflows. Smart truncation achieves the same brevity without the cognitive tax. + +**Command naming:** + +- **Case Study 1:** `view push-range` (explicit) +- **Case Study 2:** `view push` (concise) + +**Analysis:** Both work. I slightly prefer `view push` for brevity, but consistency with other `push-X` commands could justify `push-range`. Not a strong opinion. + +### Unique Insights + +**From Case Study 1:** + +- Detailed design recommendations section (§1-§9) +- Proposed named ranges (`spike:1`, `longest-frame`) +- Identified negative nanosecond timestamp bug in `view pop` +- Provided implementation timeline estimates (2-10 weeks to production ready) + +**From Case Study 2 (this):** + +- Cognitive load assessment framework (low/high cognitive load categories) +- "First-look tool" vs "primary investigation tool" distinction +- More focus on real-world workflow pain points +- Specific call-out of AMD driver symbol handling +- Emphasis on filtering as a solution to verbosity + +### Validation + +The **high degree of overlap** between independent investigations of the same profile demonstrates: + +1. ✅ The issues are real and reproducible +2. ✅ The proposed solutions are well-aligned +3. ✅ The priority rankings are consistent (module names > truncation > markers) +4. ✅ The overall assessment is reliable ("promising foundation with critical gaps") + +## Conclusion + +pq is a **promising tool** that successfully enables command-line profile investigation. The core workflow is solid, and for focused investigations (zoomed into specific time ranges), it's quite effective. + +However, **output verbosity and missing features** significantly limit its utility for complex investigations. Adding filtering, limiting, and marker viewing would transform pq from a "triage tool" into a "primary investigation tool." + +The handling of unsymbolicated code is **functional but needs UX work** - it's not a blocker, but better clarity would help users understand what they're looking at. + +**Bottom line:** pq has excellent bones, but needs refinement to handle the scale and complexity of real-world performance profiles. + +**Cross-validation with Case Study 1:** The independent investigation reached nearly identical conclusions, confirming these findings are robust and actionable. diff --git a/pq-case-study.md b/pq-case-study.md new file mode 100644 index 0000000000..eb648991a3 --- /dev/null +++ b/pq-case-study.md @@ -0,0 +1,718 @@ +# Profile Query CLI (pq) - Case Study Report + +## Profile Investigation Summary + +**Profile:** https://share.firefox.dev/4oLEjCw (Firefox 146 on Windows 11) + +### Findings + +The profile shows **bursty rendering activity** rather than sustained performance issues. Key observations: + +1. **Thread CPU Distribution:** + - GPU process (p-14): 16.1s total CPU time + - Renderer thread (t-93): 7.9s (26% active, 63.5% idle overall) + - WRWorker threads: ~1.5s each (88-95% idle) + - Parent Process GeckoMain (t-0): 7.9s (42% waiting, 26% sleeping) + +2. **Activity Pattern:** + - Baseline: 81% CPU utilization across ~30 seconds + - Spikes: 160% CPU (2 cores saturated) in bursts of 200-1000ms + - Most threads spend majority of time idle waiting for work + +3. **Active Work Breakdown:** + - GPU Renderer: 7.5% checking device state (`WaitForFrameGPUQuery`), ~9% waiting on GPU operations + - Main thread: Blocked on IPC (`SendFlushRendering`), waiting for compositor responses + - WRWorker threads: Skia rendering (`SkRasterPipelineBlitter`, path operations) + - **Limited visibility:** ~15% of time in unsymbolicated GPU driver functions (`fun_a56960`, etc.) - can't determine which library without module names + +**Diagnosis:** This is not a "slow" profile - it's a profile of normal responsive rendering with expected idle time. The system waits appropriately between frames and for GPU operations to complete. No obvious bottleneck identified, though GPU driver work (which accounts for a significant portion of time) cannot be fully characterized without library/module context. + +--- + +## pq Usability Evaluation + +### What Worked Well + +**1. Fast Profile Loading** +Loading a remote profile from share.firefox.dev was smooth and quick. The daemon model works well. + +**2. Progressive Exploration** +The workflow of `profile info` → `thread select` → `thread samples` felt natural for drilling down into threads. + +**3. View Range Zooming** +`view push-range ts-6,ts-7` successfully filtered to spike periods. The concept of pushing/popping ranges is solid. + +**4. Command Consistency** +Commands follow predictable patterns: `thread select`, `thread info`, `thread samples`. Easy to remember. + +**5. Output Quality - Thread Info** +The CPU activity timeline with indented percentages is excellent: + +``` +- 26% for 30404.1ms (14464 samples): [ts-2,ts-Yz] + - 40% for 4829.3ms (2238 samples): [ts-5,ts-Fa] + - 60% for 161.3ms (73 samples): [ts-FX,ts-FY] +``` + +This nested view clearly shows when and where CPU spikes occur. + +--- + +### Critical Issues + +**1. Missing Library/Module Names** +The single biggest problem. Output shows bare function names without context: + +- `fun_a56960`, `fun_a48860`, `fun_1159e6` - which library are these from? +- `0x7ffdbb3c8055`, `0x13c9bd2dcf1` - what module contains these addresses? + +**Context:** Graphics drivers often don't provide symbol information, so function names like `fun_a56960` are expected. However, the web UI shows **which library** the function is in (e.g., `nvoglv64.dll`, `amdvlk64.dll`, `d3d11.dll`), which is crucial for diagnosis. + +**Impact:** Cannot tell if time is spent in: + +- GPU driver code (expected for rendering) +- System libraries (might indicate OS contention) +- Unknown/JIT code (might indicate JavaScript or corrupted stacks) +- Third-party DLLs (might indicate extension issues) + +Even without function symbols, knowing "14% of time in GPU driver" vs "14% in unknown code" is the difference between actionable insight and confusion. + +**Needed:** + +- Show library/module names for all functions: `nvoglv64.dll!fun_a56960` +- Group by module in top functions: "15% in nvoglv64.dll (GPU driver)" +- Annotate unknown addresses with their module when available +- Special handling for JIT code addresses (mark as "JS JIT" if from SpiderMonkey heap) + +**2. Truncated Call Trees** +Regular call trees are cut off early, showing only 10 levels before "..." when there are clearly more levels. Example: + +``` +└─ └─ └─ └─ └─ └─ └─ └─ └─ └─ MessageLoop::Run() [total: 100.0%, self: 0.0%] +``` + +Then it just stops, even though there's clearly more interesting work below. + +**Impact:** Cannot see the actual work being done, only the dispatch machinery. + +**Needed:** + +- Show more levels by default (at least 20-30) +- Add a parameter to control depth: `--max-depth=50` or `--full-tree` +- Smart truncation: continue showing branches with >5% self time + +**3. Timestamp Display Issues** +After `view pop-range`, timestamps were shown as: + +``` +ts<78 (-3,703,142,204,026ns) to ts<79 (-3,702,751,569,159ns) +``` + +**Problems:** + +- Negative nanosecond values are meaningless to users +- Should show relative times like "2.701s to 3.092s" as push-range did +- Inconsistent between push and pop + +**4. Limited Function List** +"Top Functions" shows only 20 functions, then: + +``` +... (6603 more functions omitted, max total: 16392, max self: 6916, sum of self: 2818) +``` + +**Impact:** Cannot see secondary bottlenecks. If top function is 42% waiting (expected), I need to see what the other 58% is doing. + +**Needed:** + +- Show at least top 50 functions +- Add `--limit=N` parameter +- Better filtering: `--min-self-time=1%` to hide trivial functions + +**5. Heaviest Stack Truncation** +The heaviest stack shows "... (42 frames skipped)" in the middle: + +``` + 20. NS_ProcessNextEvent(nsIThread*, bool) + ... (42 frames skipped) + 63. nsWindow::WindowProcInternal(...) +``` + +**Impact:** Cannot see the full execution path. The skipped frames are often the most important. + +**Needed:** Never skip frames in "heaviest stack" - it's only one stack, show all frames. + +--- + +### Missing Features + +**1. Marker Support** +Commands exist in help text (`thread markers`) but aren't implemented. Markers are crucial for understanding: + +- Layout/style/reflow costs +- JavaScript function names +- IPC message types +- GPU command boundaries + +**Impact:** Major gap. Half of profiling insight comes from markers. + +**2. Time Range Selection by Content** +No way to find "ranges where thread X is >80% active" or "show me the longest frame". Currently must: + +- Read profile info manually +- Copy timestamp names +- Push range manually + +**Needed:** + +- `view find-spikes --thread t-93 --min-cpu=80%` +- `view find-longest-frame` +- `view show-frame 42` (jump to Nth frame) + +**3. Cross-Thread Analysis** +No way to see what multiple threads were doing during the same time range. Had to manually: + +- Push range for spike period +- Select thread, view samples +- Select another thread, view samples +- Mentally correlate + +**Needed:** + +- `thread compare t-0 t-93` showing both threads side-by-side +- `profile samples --all-threads` during current view range + +**4. Function Listing/Search** +No way to search for specific functions. Wanted to find all places where `nsTreeImageListener::AddCell` appears (saw it used 0.3% CPU), but had to scroll through output. + +**Needed:** + +- `thread functions` to list all functions with CPU time +- `thread functions -E "nsTree"` to filter with regex (see Design Recommendations §6) + +**5. JavaScript-Specific Commands** +No way to view just JavaScript execution: + +- Filter to JIT frames vs C++ frames +- See hot JavaScript functions +- Understand script URLs + +**6. Export/Save** +No way to save investigation results. Had to pipe to `head` manually. Would want: + +- `thread samples --output=report.txt` +- `profile export --format=json` for scripting + +--- + +### Cognitive Load Assessment + +**Learning Curve: Low ✓** + +- Commands are intuitive if you understand profiling concepts +- Help text is clear +- Predictable command structure + +**Mental Model: Good ✓** + +- Daemon/client separation is invisible (good) +- Thread selection persists across commands (good) +- View range stack metaphor is clear + +**Context Switching: Moderate** + +- Remembering timestamp names (ts-6, ts-7) is awkward +- Have to remember which thread is selected +- No way to see "current state" - need `pq status` showing: + - Current session + - Selected thread + - Current view range stack + +**Memory Burden: High** + +- Timestamp names are opaque (ts-6 vs ts-FX) +- Must remember findings from previous commands +- No way to annotate or save intermediate results + +--- + +### Output Quality + +**Profile Info: Excellent ✓✓✓** + +- Clear hierarchy (processes → threads) +- CPU percentages make relative costs obvious +- Timeline sections show burst patterns +- Top threads immediately visible + +**Thread Info: Excellent ✓✓✓** + +- Nested CPU activity is perfect for finding spikes +- Sample counts + durations both shown +- Thread lifecycle (created/ended) useful + +**Thread Samples: Good but Limited** + +- Top functions by total/self time is standard profiler output +- Inverted call tree is useful +- Heaviest stack helps identify primary path + +**Problems:** + +- Too much truncation (as detailed above) +- No percentage filter (hide <1% functions) +- Call tree depth insufficient +- Missing symbols are jarring + +--- + +### Ergonomics + +**Command Length: Mixed** + +- Short commands are nice: `thread info`, `profile info` +- Thread handles work well: `t-93` is concise +- Timestamp ranges are verbose: `view push-range ts-6,ts-7` + - **Addressed in Design Recommendations §2**: support `view push 2.7,3.1` (seconds) + +**Discoverability: Good ✓** + +- `--help` shows all commands +- Error messages are clear +- Command structure is guessable + +**Error Recovery: Needs Work** + +- No undo for thread selection (minor) +- Can't peek at view range without pushing +- No validation of timestamp names before pushing + +**Workflow Efficiency:** + +- Too many steps to compare threads during a spike +- No way to iterate quickly through interesting ranges +- Must manually correlate information across commands + +--- + +### Design Recommendations + +This section addresses key design questions that arose during the case study. + +#### 1. Timestamp Display: Always Show Both + +**Current issue:** Compact names (ts-6) are opaque; long timestamps are hard to remember. + +**Recommendation:** Show both everywhere: + +``` +Pushed view range: ts-6 (2.701s) to ts-7 (3.092s) +Popped view range: ts-6 (2.701s) to ts-7 (3.092s) +``` + +**Benefits:** + +- Compact names for scripting/reference: `view push ts-6,ts-7` +- Human-readable context for understanding +- Consistency between push and pop + +#### 2. Timestamp Range Input: Support Multiple Formats + +**Current issue:** "ts-6,ts-7" is verbose - requires copying from profile info output. + +**Recommendation:** Accept multiple formats, parse intelligently: + +```bash +view push ts-6,ts-7 # Timestamp names (current) +view push 2.7,3.1 # Relative seconds (new, most ergonomic) +view push 2.7s,3.1s # Explicit unit (new) +view push 2700ms,3100ms # Milliseconds (new) +view push 10%,20% # Percentage through profile (new) +``` + +**Default unit:** Seconds (most natural) + +**Benefits:** + +- Fast iteration: `view push 2.7,3.1` is much shorter than `view push ts-6,ts-7` +- Intuitive: "zoom into 2.7 to 3.1 seconds" is clear +- Backward compatible: timestamp names still work +- Scriptable: can compute times programmatically + +**Implementation note:** Detect format by pattern - if contains `ts-`, use name lookup; if numeric, parse as time; if contains `%`, parse as percentage. + +#### 3. Separate Commands for Sample Views + +**Current issue:** `thread samples` dumps everything. Flags like `--limit` or `--min-self-time` would apply to all sections, which is awkward. + +**Recommendation:** Split into focused commands: + +```bash +thread samples-top [--limit=N] [--min-self=1%] [--by={total|self}] + # Just top functions by total/self time + +thread samples-tree [--max-depth=N] [--min-percentage=1%] + # Just regular call tree + +thread samples-inverted [--max-depth=N] [--min-percentage=1%] + # Just inverted call tree + +thread samples-heaviest [--no-skip | --max-frames=N] + # Just heaviest stack + +thread samples # Keep for backward compatibility + # All views (current behavior) +``` + +**Benefits:** + +- Each view has appropriate parameters +- Faster output when you only need one view +- More composable with shell tools (`| grep`, `| less`) +- Can set sensible per-view defaults + +**Alternative considered:** `thread samples --view=top --limit=50` + +- Rejected: less ergonomic, harder to discover views + +#### 4. Heaviest Stack Truncation: Increase Cap with Safety Limit + +**Current issue:** 27 frames shown, 42 skipped - way too aggressive. + +**Recommendation:** + +- Default: Show up to **200 frames** (covers 99% of real stacks) +- Safety: Cap at **500 frames** to prevent terminal flooding from infinite recursion +- Flag: `--max-frames=N` to override +- Never skip in the middle - if truncated, show first N frames with clear message: + ``` + ... (300 more frames omitted - use --max-frames to see all) + ``` + +**Rationale:** + +- 200 frames handles even deep template/async stacks +- 500 frame safety net catches bugs +- Skipping frames in the middle destroys diagnostic value + +#### 5. Function Names: Smart Truncation, No Handles + +**Issue:** Long C++ names with templates are verbose and hard to scan. + +**Recommendation: Smart truncation without handles** + +Function handles (`f-234`) add cognitive overhead and indirection. Instead: + +**Length cap: 100 characters** with smart truncation: + +``` +# Original (150 chars): +std::_Hash,std::equal_to>,std::allocator>,0>>::~_Hash() + +# Truncated (100 chars): +std::_Hash>::~_Hash() +``` + +**Rules:** + +1. Keep module/library name: `nvoglv64.dll!` always shown +2. Keep actual function name: `~_Hash()` always shown +3. Truncate middle of namespaces/templates: `...` +4. Preserve enough to be unique in context + +**For call trees:** Even more aggressive (60 char limit) since indentation eats space: + +``` +mozilla::wr::RenderThread::UpdateAndRender(...) +``` + +**Benefits:** + +- No cognitive overhead of handle indirection +- Still readable at a glance +- No need for separate lookup command +- Can copy/paste into search + +**Alternative considered:** Function handles like `f-234` + +- Rejected: requires mental mapping, breaks copy/paste, adds complexity + +#### 6. Function Search: Use `thread functions` with Grep Patterns + +**Current issue:** No way to search for functions. + +**Recommendation:** + +```bash +thread functions # List all functions with CPU time +thread functions -E "nsTree" # Regex filter (like grep -E) +thread functions -i "layout" # Case-insensitive (like grep -i) +thread functions --min-self=1% # Only functions with >1% self time +``` + +**Output format:** + +``` +Functions in thread t-0 (GeckoMain): + 42.2% ZwUserMsgWaitForMultipleObjectsEx + 26.4% ZwWaitForAlertByThreadId + 8.1% NtUserMessageCall + 1.3% memset + ... (showing 45 of 6623 functions) +``` + +**Benefits:** + +- Familiar grep-style interface +- Composable: can still pipe to grep for more filtering +- Consistent with ripgrep conventions + +**Name:** `thread functions` (not `thread search`) because it's listing/filtering functions, not searching arbitrary text. + +#### 7. Command Structure: Clarify State vs Time Range + +**Current issue:** Inconsistency between `thread select` and `view push-range` - both change "view state" but use different command prefixes. + +**Recommendation: Separate concerns clearly** + +Two types of state: + +1. **Thread selection** - which thread to analyze +2. **Time range** - which time window to analyze + +**Proposed structure:** + +```bash +# Thread selection +thread select t-93 # Select thread +thread info # Info for selected thread +thread samples-top # Samples for selected thread + +# Time range (keep "view" for time, since it's the "view" into the timeline) +view push ts-6,ts-7 # Push time range +view pop # Pop time range +view clear # Clear all ranges (back to full profile) +view list # Show range stack + +# Status (what's my current context?) +status # Show session, selected thread, range stack + # Output: + # Session: ttzltpqjsi (profile: https://share.firefox.dev/4oLEjCw) + # Thread: t-93 (Renderer) + # View ranges: [ts-6 (2.701s) → ts-7 (3.092s)] +``` + +**Alternative considered:** `time-range push` instead of `view push` + +- Rejected: "view" is shorter, intuitive (you're changing your view of the timeline) +- "time-range" is verbose and awkward + +**Alternative considered:** `view` command shows status + +- Rejected: `view push/pop/clear` makes `view` ambiguous (verb vs noun) +- Better to have explicit `status` command + +**Benefits:** + +- Clear separation: `thread` = which thread, `view` = which time +- Consistent: all state changes are explicit commands +- `status` shows everything at once + +#### 8. Function Name Repetition: Acceptable with Module Context + +**Issue:** Function names appear many times in call tree output. + +**Analysis:** This is actually fine and expected: + +- Call trees inherently repeat names (parent nodes) +- Module prefixes (`nvoglv64.dll!`) add context, not noise +- Truncation (rule #5) keeps length manageable +- Terminal scrollback handles repetition well + +**No action needed.** The proposed module display and truncation rules are sufficient. + +#### 9. View Range Ergonomics: Range Names for Common Patterns + +**Additional idea:** For common access patterns, support named ranges: + +```bash +view push spike:1 # First detected CPU spike >80% +view push spike:next # Next spike after current range +view push frame:5 # 5th vsync frame (if markers present) +view push longest-frame # Longest frame in profile +``` + +**Implementation:** These would be computed on-demand, not persisted. + +**Benefits:** + +- Very fast exploration: "show me the spikes" +- No need to manually parse profile info output +- Great for CI/CD: "report on longest frame" + +**Priority:** Medium (do after basic time format support) + +--- + +### Specific Improvements Needed + +See **Design Recommendations** section above for detailed proposals on command structure, timestamp formats, and function display. + +**High Priority:** + +1. **Show library/module names** - essential context even without symbols (Design Rec. §1) +2. **Fix timestamp display** - show both compact name and readable time (Design Rec. §1) +3. **Support time formats** - accept seconds, ms, % in addition to timestamp names (Design Rec. §2) +4. **Separate sample commands** - `thread samples-top`, `samples-tree`, etc. (Design Rec. §3) +5. **Deeper call trees** - show 30+ levels by default, cap at 200 for call trees +6. **Fix heaviest stack truncation** - show up to 200 frames, never skip middle (Design Rec. §4) +7. **Implement markers** - huge gap in functionality + +**Medium Priority:** + +8. **Status command** - show session/thread/range state (Design Rec. §7) +9. **Function listing** - `thread functions -E "pattern"` (Design Rec. §6) +10. **Smart function truncation** - 100 char cap, preserve module + function name (Design Rec. §5) +11. **Cross-thread views** - compare threads during same range +12. **Named ranges** - `view push spike:1`, `longest-frame` (Design Rec. §9) + +**Low Priority:** + +13. **Export results** - save to file +14. **Progress indicators** - loading large profiles +15. **Color output** - highlight high percentages in output + +--- + +### Comparison to Web UI + +**pq Advantages:** + +- Much faster for quick triage +- Easy to script/automate +- Lower memory usage +- Works over SSH +- Can process many profiles in batch + +**Web UI Advantages:** + +- Visual timeline shows everything at once +- Mouse hover reveals details instantly +- Can see multiple threads simultaneously +- Marker tooltips show rich information +- Source view integration +- Network panel, memory tracks, etc. + +**Ideal Use Cases for pq:** + +- Quick "what's slow?" triage +- CI/CD performance monitoring +- Batch analysis of many profiles +- Server-side investigation (no GUI) +- Extracting specific data for reports + +**Where pq Falls Short:** + +- Complex multi-thread timing issues +- Understanding frame scheduling +- Correlating markers with samples +- Visual pattern recognition +- Exploratory analysis without hypothesis + +--- + +### Overall Assessment + +**Current State: Promising Foundation (60% there)** + +pq successfully demonstrates that CLI profiling is viable and valuable. The core architecture (daemon model, thread selection, view ranges) is sound. For profiles where you can identify which libraries are consuming time and single-threaded bottlenecks, it works reasonably well. + +**Critical Gaps:** + +- Library/module context is essential - without it, functions are unidentifiable blobs +- Output truncation hides too much information +- Missing marker support eliminates half of profiling value + +**Recommendation:** +Fix the three critical gaps above before adding new features. A tool that shows incomplete information (truncated trees, missing module context, no markers) frustrates users more than missing features. + +**Potential:** +If library names, depth, and markers are addressed, pq could become the standard first-response tool for performance issues. "Run pq first, open web UI if needed" would be a great workflow. + +**Estimated to "Production Ready":** + +- With critical fixes: 2-3 weeks +- With medium priority features: 4-6 weeks +- With low priority polish: 8-10 weeks + +The foundation is solid. The gaps are addressable. The value proposition is clear. + +--- + +### Summary of Key Design Decisions + +Based on the case study investigation, here are the recommended design directions: + +**1. Command Structure** (Design Rec. §7) + +```bash +thread select t-93 # Select which thread +thread samples-top # View top functions (separate commands per view) +view push 2.7,3.1 # Push time range (view = time window) +status # Show current state +``` + +- `thread` for thread operations, `view` for time ranges, `status` for context +- No function handles (f-234) - use smart truncation instead + +**2. Time Range Input** (Design Rec. §2) + +```bash +view push ts-6,ts-7 # Timestamp names (keep for compatibility) +view push 2.7,3.1 # Seconds (NEW, default - most ergonomic) +view push 2700ms,3100ms # Milliseconds (NEW) +view push 10%,20% # Percentage (NEW) +``` + +- Always display both: "ts-6 (2.701s)" in output + +**3. Sample View Commands** (Design Rec. §3) + +- Separate commands: `samples-top`, `samples-tree`, `samples-inverted`, `samples-heaviest` +- Each has appropriate flags: `--limit`, `--max-depth`, `--min-self`, etc. +- Keep `thread samples` for backward compatibility (shows all) + +**4. Function Display** (Design Rec. §5) + +- Show module names: `nvoglv64.dll!fun_a56960` +- Smart truncation: 100 chars max, preserve module + function name +- Call trees: 60 chars (indentation eats space) +- No function handles + +**5. Output Limits** + +- Top functions: Show 50 by default (was 20) +- Call tree depth: Show 30+ levels by default (was ~10) +- Heaviest stack: Show 200 frames (was 27), never skip middle +- Safety cap: 500 frames max to catch infinite recursion + +**6. Function Search** (Design Rec. §6) + +```bash +thread functions # List all with CPU% +thread functions -E "nsTree" # Regex filter (grep-style) +``` + +**7. Status/Context** + +```bash +status # Show session, selected thread, view range stack +``` + +These decisions prioritize: + +- **Ergonomics**: `view push 2.7,3.1` is much faster than `view push ts-6,ts-7` +- **Consistency**: Clear separation between `thread` (which) and `view` (when) +- **Readability**: Module names and smart truncation over handles +- **Composability**: Separate commands work better with pipes/scripts +- **Discoverability**: Grep-style flags, clear command names diff --git a/pq-filters-and-bookmarks-proposal.md b/pq-filters-and-bookmarks-proposal.md new file mode 100644 index 0000000000..de3fb18408 --- /dev/null +++ b/pq-filters-and-bookmarks-proposal.md @@ -0,0 +1,750 @@ +# pq Filters and Bookmarks Proposal + +**Status:** Proposal (revised based on feedback) +**Created:** 2025-01-04 +**Last Updated:** 2025-01-04 +**Related:** pq-todo.md + +--- + +## Key Design Decisions + +Based on feedback, this proposal includes: + +1. ✅ **Consistent terminology** - `push/pop/clear` for zoom and filters, `load/unload` for bookmarks +2. ✅ **Multi-thread selection** - `thread select t-0,t-93` works for sticky state +3. ✅ **Clear OR vs AND** - `-any` suffix for OR, repeated flags for AND +4. ✅ **Per-profile bookmarks** - scoped to current profile, not global +5. ✅ **Zoom validation** - nested ranges must be contained within parent +6. ✅ **Unified naming** - `--includes-prefix` instead of `--starts-with-function`/`--starts-with-sequence` +7. ✅ **Balanced zoom syntax** - `zoom push ts-6,ts-7` and `zoom push --marker m-158` +8. ✅ **Single filter stack** - one ordered stack for sample/stack filters (order matters for dependencies) +9. ✅ **Prefix means exact sequence** - `--includes-prefix A,B,C` means starts with A→B→C exactly +10. ✅ **Per-thread filter stacks** - each thread has its own filter context (handles are thread-specific!) +11. ✅ **Separate marker filters** - marker display filtering independent from sample/stack filtering + +--- + +## Overview + +This proposal defines a comprehensive system for managing analysis state in pq with **four independent dimensions**: + +1. **Thread selection** (global) - which thread(s) you're analyzing +2. **Zoom** (global) - time range you're focused on +3. **Sample/Stack filters** (per-thread) - how to filter and transform samples +4. **Marker filters** (per-thread) - how to filter marker display + +Each dimension supports: + +- **Ephemeral use** - apply once via flags +- **Sticky state** - persists across commands via push/pop/clear +- **Bookmarks** - save and restore complex views + +--- + +## Design Principles + +1. **Ephemeral by default** - All commands accept filter flags that apply only to that invocation +2. **Explicit stickiness** - Making state sticky requires explicit commands (`select`, `zoom push`, `filter add`) +3. **Clear state** - `pq status` always shows current thread, zoom, and active filters +4. **Composable** - Filters, zoom, and thread selection are independent dimensions +5. **Saveable** - Complex views can be bookmarked and recalled + +--- + +## Core Syntax + +### 1. Ephemeral Filters (Flags) + +All commands accept filter flags that apply only to that command: + +```bash +# Ephemeral thread selection +pq thread samples --thread t-93 +pq thread markers --threads t-0,t-93 + +# Ephemeral zoom +pq thread samples --zoom ts-6,ts-7 +pq thread markers --zoom m-158 + +# Ephemeral sample filters +pq thread samples --includes-any-function PresentImpl,FlushD3D11 +pq thread samples --during-marker --search Paint +pq thread samples --includes-prefix f-1,f-2,f-3 + +# Ephemeral stack transforms +pq thread samples --merge malloc,free,arena_dalloc +pq thread samples --root-at f-142 +pq thread samples --strip-prefix f-1,f-2,f-3 + +# Combinations work +pq thread samples --thread t-93 --zoom ts-6,ts-7 --merge malloc --limit 20 +``` + +### 2. Sticky Thread Selection + +```bash +pq thread select t-93 # Select single thread (sticky) +pq thread samples # Uses t-93 + +pq thread select t-0,t-93 # Select multiple threads (sticky) +pq thread samples # Uses both threads + +pq thread select t-0 # Switch to different thread +``` + +### 3. Sticky Zoom (Stack-based) + +```bash +pq zoom push ts-6,ts-7 # Push zoom level +pq thread samples # Uses zoomed range + +pq zoom push ts-6a,ts-6c # Zoom further (within previous range) +pq thread samples # Uses nested zoom + +pq zoom pop # Pop one zoom level (back to ts-6,ts-7) +pq zoom pop # Pop again (back to full profile) + +pq zoom clear # Clear entire zoom stack + +# Convenience forms +pq zoom push m-158 # Zoom to marker's time range (✅ implemented) +pq zoom push --spike 1 # Zoom to first CPU spike (future feature) +``` + +### 4. Sticky Sample/Stack Filters (Per-Thread) + +Each thread has its own filter stack. Filters are **scoped to the current thread** because function handles (f-142) are thread-specific. + +```bash +# Select thread first +pq thread select t-93 + +# Push filters onto THIS THREAD's stack +# Each filter is applied in push order + +# Sample filters (which samples to include) +pq filter push --includes-any-function f-142,f-143 # Filter 1: includes f-142 OR f-143 +pq filter push --includes-function f-142 # Filter 2: AND (must also have f-142) +pq filter push --includes-function f-200 # Filter 3: AND (must also have f-200) + +pq filter push --during-marker --search Paint # Filter 4: during Paint markers +pq filter push --includes-prefix f-100,f-200 # Filter 5: stack starts f-100→f-200 +pq filter push --includes-suffix f-142 # Filter 6: stack ends with f-142 + +# Inverse sample filters (exclude) +pq filter push --excludes-any-function malloc,free # Exclude samples +pq filter push --outside-marker --search GC + +# Stack transform filters (modify stacks before sample filtering) +pq filter push --strip-prefix f-1,f-2,f-3 # Strip then sample-filter +pq filter push --merge malloc,free,arena_dalloc # Merge away allocators +pq filter push --merge-regex "^(malloc|free|moz_x)" +pq filter push --root-at f-142 # Re-root stacks +pq filter push --strip-suffix f-999 + +# Order matters! Example: +pq filter push --strip-prefix A,B,C # 1. Strip A→B→C from stacks +pq filter push --includes-prefix D # 2. Then filter by prefix D +# Filter 2 sees stacks AFTER filter 1 has transformed them + +# Management +pq filter list # Show filters for current thread +pq filter pop # Pop most recent filter +pq filter pop 3 # Pop 3 most recent filters +pq filter clear # Clear all filters for current thread + +# Switch threads - different filter stack! +pq thread select t-0 +pq filter list # t-0's filters (independent from t-93) + +# Filters apply to sample analysis commands +pq thread samples # Uses current thread's filters +``` + +### 5. Sticky Marker Filters (Per-Thread) + +Each thread also has its own marker filter stack, independent from sample/stack filters. + +```bash +# Select thread +pq thread select t-0 + +# Push marker display filters onto THIS THREAD's marker filter stack +pq marker filter push --search Paint +pq marker filter push --category Graphics +pq marker filter push --min-duration 5 +pq marker filter push --max-duration 100 +pq marker filter push --has-stack + +# Marker filters affect marker display +pq thread markers # Shows only filtered markers + +# Management +pq marker filter list # Show marker filters for current thread +pq marker filter pop # Pop most recent marker filter +pq marker filter clear # Clear all marker filters for current thread + +# Switch threads - different marker filter stack! +pq thread select t-93 +pq marker filter list # t-93's marker filters (independent from t-0) + +# Marker filters only affect marker display, not sample analysis +pq thread samples # NOT affected by marker filters +pq thread markers # Affected by marker filters +``` + +**Note:** Sample/stack filters that reference markers (e.g., `--during-marker --search Paint`) operate on the **unfiltered** marker set. Marker filters only affect display, not sample filtering logic. + +### 6. Bookmarks + +Bookmarks save complex views for later recall. They are **per-profile** (scoped to the current session's profile). + +```bash +# Create bookmarks +pq bookmark view spike1 --zoom ts-6,ts-7 --threads t-0,t-93 +pq bookmark filter no-allocators --merge malloc,free,arena_dalloc + +# Use bookmarks ephemerally +pq thread samples --view spike1 +pq thread samples --filter no-allocators +pq thread samples --view spike1 --filter no-allocators + +# Load bookmarks (make sticky) +pq bookmark load view spike1 # Sets zoom + threads sticky +pq bookmark load filter no-allocators # Applies filters sticky + +# Management +pq bookmark list # Show all bookmarks for current profile +pq bookmark info spike1 # Show bookmark details +pq bookmark delete spike1 # Remove bookmark +pq bookmark export spike1 > spike1.json # Export for sharing (future) +pq bookmark import spike1.json # Import bookmark (future) +``` + +### 7. Status Command + +Always shows current state across all dimensions: + +```bash +pq status + +# Output: +Session: 3ugy6phmzqc +Profile: https://share.firefox.dev/4oLEjCw +Thread: t-93 (Renderer) +Zoom: ts-6,ts-7 (2.701s - 3.091s) + └─ parent: full profile (0s - 30.5s) + +Sample/Stack Filters for t-93 (applied in order): + 1. [stack transform] merge: malloc, free, arena_dalloc, je_malloc + 2. [sample filter] includes function: f-142 (dxgi.dll!CDXGISwapChain::PresentImpl) + 3. [sample filter] during markers matching: --search Paint + +Marker Filters for t-93: + 1. search: Paint + 2. min-duration: 5ms + +Filters for other threads: + t-0: 2 sample/stack filters, 1 marker filter + t-99: 1 sample/stack filter, 0 marker filters + +Bookmarks loaded: + - view: spike1 + - filter: no-allocators +``` + +--- + +## Filter Types + +### Sample Filters (Inclusion/Exclusion) + +Control which samples are included in analysis: + +```bash +# Include samples where... +# OR semantics: use -any suffix with comma-separated list +--includes-any-function f-142,f-143 # Stack contains f-142 OR f-143 +--includes-function f-142 # Stack contains this function (can repeat for AND) + +--includes-prefix f-1,f-2,f-3 # Stack starts with this sequence +--includes-suffix f-999 # Stack ends with this function + +--during-marker --search Paint # Timestamp falls within matching marker +--during-marker --category Graphics # Timestamp falls within marker in category + +# Exclude samples where... +--excludes-any-function malloc,free # Stack does not contain malloc OR free +--excludes-function malloc # Stack does not contain this function +--outside-marker --search GC # Timestamp outside matching markers + +# Sample filters affect: +# - thread samples (which samples aggregate) +# - thread markers (which samples contribute to marker statistics) +# - thread functions (which samples count toward function time) + +# Combining filters: +# - Same flag repeated = AND (--includes-function f-1 --includes-function f-2 = has both) +# - -any suffix = OR (--includes-any-function f-1,f-2 = has either) +``` + +### Stack Transform Filters + +Modify stack traces in all samples: + +```bash +# Merge (remove functions from stacks, collapse callers to callees) +--merge malloc,free # Remove these functions +--merge-regex "^(malloc|free)" # Remove matching functions +# A -> malloc -> B becomes A -> B + +# Root-at (show only subtree rooted at function) +--root-at f-142 # Show only time within this function +# Only shows stacks that include f-142, with f-142 as root + +# Strip prefix (remove leading frames) +--strip-prefix f-1,f-2,f-3 # Remove these from stack tops +# f-1 -> f-2 -> f-3 -> f-4 becomes f-4 + +# Strip suffix (remove trailing frames) +--strip-suffix f-999 # Remove these from stack bottoms +# f-1 -> f-2 -> f-999 becomes f-1 -> f-2 + +# Stack transforms affect: +# - thread samples (how stacks are displayed) +# - thread functions (which functions appear in list) +# - Call tree structure +``` + +### Thread and Zoom (Spatial/Temporal) + +Not technically "filters" but part of the view context: + +```bash +# Thread selection +--thread t-93 # Single thread (ephemeral) +--threads t-0,t-93 # Multiple threads (ephemeral) +pq thread select t-93 # Single thread (sticky) + +# Zoom (time range) +--zoom ts-6,ts-7 # Specific range (ephemeral) +--zoom m-158 # Marker's time range (ephemeral) +pq zoom push ts-6,ts-7 # Push range (sticky) +pq zoom push m-158 # Push marker's range (sticky, ✅ implemented) +``` + +--- + +## Bookmark Types + +### View Bookmarks + +Capture spatial/temporal context (where/when you're looking): + +```bash +pq bookmark view spike1 --zoom ts-6,ts-7 --threads t-0,t-93 +pq bookmark view gpu-idle --thread t-93 --zoom ts-p,ts-q + +# View bookmarks include: +# - Time range (zoom level) +# - Thread selection (single or multiple) +``` + +### Filter Bookmarks + +Capture analytical transforms (how you're analyzing): + +```bash +pq bookmark filter no-allocators \ + --merge malloc,free,arena_dalloc,je_malloc \ + --merge-regex "^moz_x" + +pq bookmark filter paint-only \ + --during-marker --search Paint \ + --root-at f-50 + +# Filter bookmarks include: +# - Sample filters (includes/excludes) +# - Stack transforms (merge/root/strip) +``` + +### Using Bookmarks + +```bash +# Ephemeral use (doesn't change state) +pq thread samples --view spike1 +pq thread samples --filter no-allocators +pq thread samples --view spike1 --filter no-allocators + +# Sticky load (changes state) +pq bookmark load view spike1 # Sets thread + zoom +pq bookmark load filter no-allocators # Applies filters +pq status # Shows loaded bookmarks +pq thread samples # Uses loaded context + +# Unload +pq bookmark unload view # Clear view bookmark +pq bookmark unload filter # Clear filter bookmark +pq bookmark unload all # Clear all bookmarks +``` + +--- + +## Example Scenarios + +### Scenario 1: Quick CPU Spike Investigation + +```bash +# See profile overview +$ pq profile info +# Output shows: "160% CPU for 390ms [ts-6,ts-7]" + +# Quick peek at that spike +$ pq thread samples --zoom ts-6,ts-7 --limit 20 +# Looks interesting, main thread doing Paint + +# Check GPU thread during same time +$ pq thread samples --thread t-93 --zoom ts-6,ts-7 --limit 20 +# GPU doing Present + +# Make spike sticky to investigate further +$ pq zoom push ts-6,ts-7 +$ pq thread select t-0 +$ pq thread samples + +# Check markers from both threads +$ pq thread markers --threads t-0,t-93 +# See WM_PAINT on t-0, Composite on t-93 at same time + +# Done with spike +$ pq zoom pop +``` + +### Scenario 2: Eliminating Allocator Noise + +```bash +# Call tree is noisy +$ pq thread samples +# Lots of malloc/free/arena_dalloc + +# Try merging ephemerally +$ pq thread samples --merge malloc,free,arena_dalloc --limit 30 +# Better! See actual work + +# Make it sticky +$ pq filter push --merge malloc,free,arena_dalloc,je_malloc +$ pq filter push --merge-regex "^moz_x" +$ pq thread samples +# Clean call tree across all commands + +# Save for future use +$ pq bookmark filter no-allocators --merge malloc,free,arena_dalloc,je_malloc --merge-regex "^moz_x" + +# Later session (on same profile) +$ pq bookmark load filter no-allocators +``` + +### Scenario 3: Analyzing Time in Specific Function + +```bash +# Find expensive function +$ pq thread functions --search PresentImpl +# f-142. dxgi.dll!CDXGISwapChain::PresentImpl - 16.4% total + +# See what it's calling (ephemeral root) +$ pq thread samples --root-at f-142 --limit 30 +# Shows subtree rooted at PresentImpl + +# Also filter to only samples that include it +$ pq filter push --includes-function f-142 +$ pq filter push --root-at f-142 +$ pq thread samples +# Now analyzing only time within PresentImpl + +# Check markers during this work +$ pq thread markers +# Sample filters affect marker aggregation too + +# Clear when done +$ pq filter clear +``` + +### Scenario 4: Cross-Thread Causality Chain + +```bash +# Main thread fires Paint +$ pq thread select t-0 +$ pq marker info m-158 +# WindowProc WM_PAINT at 1h2m (33.52ms) + +# Zoom to that marker +$ pq zoom push m-158 + +# See what GPU did during same time +$ pq thread samples --thread t-93 --limit 20 +# GPU doing Present work + +# Make GPU thread sticky +$ pq thread select t-93 + +# Only look at samples during Paint markers +$ pq filter push --during-marker --search Paint +$ pq thread samples +# Now only see GPU work that happened during Paint + +# Save this analysis (bookmarks are per-profile) +$ pq bookmark view paint-causality --zoom m-158 --thread t-93 +$ pq bookmark filter paint-only --during-marker --search Paint + +# Later: reload entire analysis (same profile) +$ pq bookmark load view paint-causality +$ pq bookmark load filter paint-only +$ pq thread samples +``` + +--- + +## Implementation Notes + +### Per-Thread Filter Scoping + +**Why per-thread?** Function handles (e.g., `f-142`) are thread-specific. A function handle on thread t-0 has no meaning on thread t-93. + +```bash +pq thread select t-0 +pq thread functions --search Present +# f-142. dxgi.dll!CDXGISwapChain::PresentImpl on t-0 + +pq filter push --includes-function f-142 # Filter for t-0 + +pq thread select t-93 +# f-142 on t-93 is a DIFFERENT function! +# t-93 has its own independent filter stack (empty initially) +``` + +**Behavior when switching threads:** + +- Each thread maintains its own sample/stack filter stack +- Each thread maintains its own marker filter stack +- Zoom is global (applies across all threads) +- Thread selection is global (which thread is currently active) + +**Status summary:** + +- `pq status` shows filters for current thread in detail +- Lists other threads with filter count summaries + +### Filter Application Order + +Sample/stack filters are applied in **push order** (the order you pushed them onto the stack). + +This is critical because filters can depend on each other: + +```bash +# Example: Strip prefix, then filter by new prefix +pq filter push --strip-prefix A,B,C # 1. Transform: remove A→B→C +pq filter push --includes-prefix D # 2. Filter: include only stacks starting with D +# Filter 2 sees stacks AFTER filter 1's transformation + +# Example: Filter samples, then merge stacks +pq filter push --includes-function PresentImpl # 1. Only samples with PresentImpl +pq filter push --merge malloc,free # 2. Merge allocators in those samples +``` + +**General guidance for typical use:** + +1. Stack transforms first (--strip-prefix, --merge, --root-at) +2. Sample filters second (--includes, --excludes, --during-marker) + +But the single ordered stack gives you flexibility when needed. + +### Marker Filters vs Sample Filters Using Markers + +**Two different concepts:** + +1. **Marker filters** - affect marker display only + + ```bash + pq marker filter push --search Paint + pq thread markers # Shows only Paint markers + ``` + +2. **Sample filters using markers** - filter samples based on marker timing + ```bash + pq filter push --during-marker --search Paint + pq thread samples # Shows only samples during Paint markers + ``` + +**Important:** Sample filters that reference markers (e.g., `--during-marker`) use the **unfiltered** marker set, not the display-filtered set. Marker filters only affect `pq thread markers` output. + +### Zoom Stack Behavior + +Zoom works like a stack, and nested zooms must be contained within parent ranges: + +```bash +pq zoom push ts-6,ts-7 # Stack: [full profile, ts-6 to ts-7] +pq zoom push ts-6a,ts-6c # Stack: [full profile, ts-6 to ts-7, ts-6a to ts-6c] + # Validates ts-6a,ts-6c is within ts-6,ts-7 +pq zoom pop # Stack: [full profile, ts-6 to ts-7] +pq zoom pop # Stack: [full profile] +pq zoom pop # No-op, already at root + +# Invalid: non-nested range +pq zoom push ts-6,ts-7 +pq zoom push ts-A,ts-B # Error: ts-A,ts-B not within ts-6,ts-7 +``` + +### Filter Stack Behavior + +Filters form a **single ordered stack** regardless of filter type: + +```bash +pq filter push --includes-function f-142 # Stack: [f-142] +pq filter push --during-marker --search Paint # Stack: [f-142, Paint] +pq filter push --merge malloc # Stack: [f-142, Paint, malloc] + +pq filter list +# Shows: 3 filters in order +# 1. [sample filter] includes function: f-142 +# 2. [sample filter] during markers: --search Paint +# 3. [stack transform] merge: malloc + +pq filter pop # Remove most recent filter (malloc) + # Stack: [f-142, Paint] + +pq filter pop 2 # Remove 2 most recent filters + # Stack: [] + +pq filter clear # Remove ALL filters + # Stack: [] + +# Save complex filter stacks via bookmarks +pq bookmark filter my-analysis --includes f-142 --during-marker --search Paint --merge malloc +``` + +### Bookmark Storage + +Bookmarks are **per-profile** and session-local (stored in daemon memory): + +- Scoped to the current profile URL/path +- Lost when session ends (`pq stop`) +- Not shared across different profiles or sessions +- Loading a different profile = different set of bookmarks +- Future: could persist to ~/.pq/bookmarks// for cross-session use +- Future: export/import for sharing with colleagues + +### Status Command + +`pq status` output structure: + +``` +Session: +Thread: () +Zoom: + [└─ parent: ] # If zoomed + [└─ parent: ] # Can be nested +[Filters (applied in order):] + [N. [type] ] + [...] +[Bookmarks loaded:] + [- view: ] + [- filter: ] +``` + +Filter types displayed: + +- `[sample filter]` - includes/excludes samples +- `[stack transform]` - modifies stacks +- `[marker filter]` - temporal filtering + +--- + +## Future Enhancements + +### Auto-generated Bookmarks + +System could create bookmarks automatically: + +```bash +pq profile hotspots +# Output: +# 1. [ts-6,ts-7] 160% CPU for 390ms +# bookmark: spike:1 +# 2. [ts-A,ts-b] 160% CPU for 450ms +# bookmark: spike:2 + +pq bookmark load view spike:1 +# Automatically created bookmarks +``` + +### Bookmark Export/Import + +```bash +pq bookmark export spike1 > spike1.json +pq bookmark import spike1.json + +# Share bookmarks with colleagues +``` + +### Bookmark Aliases + +```bash +pq bookmark alias s1 spike1 +pq thread samples --view s1 +``` + +### Smart Merge Sets + +```bash +pq bookmark filter no-alloc --merge @allocators +# @allocators = predefined set: malloc, free, arena_dalloc, etc. + +pq bookmark filter no-overhead --merge @profiler-overhead +# @profiler-overhead = profiler stack frames +``` + +--- + +## Open Questions + +1. ✅ **Multiple threads in sticky state?** - RESOLVED + - `thread select t-0,t-93` supports multi-thread selection + +2. ✅ **Filter combination logic?** - RESOLVED + - Use `-any` suffix for OR: `--includes-any-function f-1,f-2` + - Repeated flags for AND: `--includes-function f-1 --includes-function f-2` + +3. ✅ **Zoom validation?** - RESOLVED + - Nested zooms must be contained within parent ranges + - Error if attempting to zoom to non-nested range + +4. ✅ **Bookmark namespaces?** - RESOLVED + - Bookmarks are per-profile (scoped to current profile) + +5. ✅ **Prefix semantics?** - RESOLVED + - `--includes-prefix f-1,f-2,f-3` means exactly f-1→f-2→f-3 sequence + +6. ✅ **Filter application order?** - RESOLVED + - Single ordered stack, applied in push order + - Order matters (can strip prefix then filter by new prefix) + +--- + +## Summary + +This proposal creates a consistent, composable system for managing analysis state in pq: + +- **Ephemeral filters** via flags work everywhere +- **Sticky state** via explicit push/pop/clear commands +- **Consistent terminology**: push/pop/clear for zoom and filters, load/unload for bookmarks +- **Stack-based**: zoom and filters can be layered and unwound +- **Single filter stack**: one ordered stack for all filters (order matters!) +- **Filter dependencies**: can transform stacks then filter samples +- **OR vs AND semantics**: `-any` suffix for OR, repeated flags for AND +- **Per-profile bookmarks**: scoped to current profile +- **Nested zoom validation**: ranges must be properly contained +- **Status** always shows current context including filter order +- **Composable** - filters, zoom, threads are independent dimensions + +The system is designed to be discoverable (flags are ephemeral by default) while supporting power-user workflows (sticky state and bookmarks). diff --git a/pq-todo.md b/pq-todo.md new file mode 100644 index 0000000000..2d4dda6b6a --- /dev/null +++ b/pq-todo.md @@ -0,0 +1,488 @@ +# pq To-Do List + +Feature wishlist and improvement ideas for the pq profile query tool. + +--- + +## Active Proposals + +**[Filters and Bookmarks System](pq-filters-and-bookmarks-proposal.md)** - Comprehensive design for ephemeral vs sticky state, filter system, and bookmarks. + +--- + +## Critical Priority (Blocking Effective Use) + +### 1. Persistent Context Display ✅ + +Every command output displays current context in a compact header: + +``` +[Thread: t-0 (GeckoMain) | View: Full profile | Full: 30.42s] +[Thread: t-0 (GeckoMain) | View: ts-Fo→ts-Fu (851.1ms) | Full: 30.42s] +[Thread: t-0,t-93 (GeckoMain, Renderer) | View: Full profile | Full: 30.42s] +``` + +### 2. Function Search/Filter ✅ + +Commands available: + +```bash +pq thread functions # List all functions with CPU% +pq thread functions --search Present # Substring search +pq thread functions --min-self 1 # Filter by self time percentage +pq thread functions --limit 50 # Limit results +``` + +### 3. Smart Range Navigation ⚠️ + +**Status:** Partially implemented - marker handles work, CPU spike navigation doesn't yet + +**Implemented:** + +```bash +pq zoom push m-158 # Zoom to marker's time range +``` + +**Still needed:** + +```bash +pq profile hotspots # List all high-CPU periods +pq zoom push --spike 1 # Jump to first spike +pq zoom push --spike next # Next spike from current position +pq profile hotspots --min-cpu 150% # Find sustained >150% periods +``` + +**Enhancement:** Named bookmarks: + +```bash +pq zoom push ts-Fo,ts-Fu --name "resize-thrash" +pq zoom list +pq zoom push resize-thrash +``` + +### 4. Cross-Thread Marker View ✅ + +Commands available: + +```bash +pq thread markers --thread t-0,t-93 # Merged view of specific threads +pq thread functions --thread t-0,t-93 # Functions from multiple threads +pq thread samples --thread t-0,t-93 # Samples from multiple threads +pq thread select t-0,t-93 # Select threads (sticky) +``` + +**Not yet implemented:** + +```bash +pq marker related m-158 # Show markers on other threads at same time +``` + +--- + +## High Priority (Significant Value) + +### 5. Relative Handle References ❌ + +**Problem:** Must scroll back to find handles like "m-168" when investigating + +**Proposed:** + +```bash +pq marker info m-@1 # First marker in last listing +pq marker info m-@last # Last marker in last listing +pq marker info m-@longest # Longest duration in last listing +pq marker info m-@prev # Previously inspected marker + +pq function expand f-@1 # First function in last listing +pq function expand f-@highest # Highest self-time +``` + +**Alternative:** Show rank in listings: + +``` +Markers in thread t-0 — 50 markers + #1 → m-147 (28.89ms) Runnable + #2 → m-148 (15.23ms) Runnable +``` + +Then allow: `pq marker info #1` or `pq marker info @1` + +### 6. Stack Availability Indicators ✅ + +Visual indicators (✓/✗) next to marker handles with legend: + +``` +Markers in thread t-0 (Parent Process) — 50 markers (filtered from 258060) +Legend: ✓ = has stack trace, ✗ = no stack trace + +By Name (top 15): + SimpleTaskQueue::AddTask 7 markers (instant) + Examples: m-25 ✓, m-26 ✓, m-27 ✓ + Runnable 15 markers (interval: min=1µs, avg=285µs) + Examples: m-20 ✗ (3.95ms), m-21 ✗ (61µs) +``` + +### 7. Bottom-Up Call Tree ✅ + +Command available: + +```bash +pq thread samples-bottom-up # Bottom-up call tree +``` + +Shows inverted call tree starting from leaf functions, directly answering "what code paths lead to this bottleneck?" + +### 8. Sample Output Filtering ⚠️ + +**Status:** Partially implemented (--limit exists for markers but not samples) + +**Flags needed:** + +```bash +pq thread samples --limit 30 # Top 30 functions only +pq thread samples --min-self 1% # Hide functions <1% self time +pq thread samples --max-depth 15 # Limit tree depth +pq thread samples --top-only # Skip call trees +pq thread samples --tree-only # Skip top functions list +``` + +**Enhancement:** Show truncation stats when call tree is cut off: + +``` +Regular Call Tree (showing top 30 functions, 249 lines omitted): + (root) [total: 100.0%] + └─ ... + +[249 lines omitted: 142 unique functions, max self time 0.3%, cumulative 2.1%] +``` + +### 9. Dual Percentages When Zoomed ✅ + +When zoomed, shows both view and full profile percentages: + +```bash +# When zoomed: +Functions (by self time): + f-1. win32u.dll!ZwUserMsgWaitForMultipleObjectsEx - self: 2024 (39.8% of view, 12.3% of full) + +# When not zoomed: +Functions (by self time): + f-1. win32u.dll!ZwUserMsgWaitForMultipleObjectsEx - self: 6916 (42.2%), total: 6916 (42.2%) +``` + +### 10. Inline Thread Selection ⚠️ + +**Proposed:** + +```bash +pq thread samples --thread t-93 # Query without selecting +pq thread info --thread t-0 # Peek at thread +pq thread markers --thread t-93 --search Paint +``` + +### 11. Function Info Shows Full Name ✅ + +The `pq function info` command displays both full and short names: + +```bash +Function f-1: + Thread: t-0 (GeckoMain) + Full name: win32u.dll!ZwUserMsgWaitForMultipleObjectsEx + Short name: ZwUserMsgWaitForMultipleObjectsEx + Is JS: false + ... +``` + +### 12. Module-Level Grouping ❌ + +**Proposed:** + +``` +Top Functions (by self time): + 63.5% ntdll.dll!ZwWaitForAlertByThreadId + 8.6% ntdll.dll!NtWaitForSingleObject + ... + +Module Summary: + 14.3% atidxx64.dll (AMD GPU Driver - 23 functions) + 12.8% xul.dll (Firefox Core - 156 functions) + 9.2% ntdll.dll (Windows NT - 12 functions) +``` + +### 13. Profile Summary Command ❌ + +**Proposed:** + +```bash +pq summary # Overall profile summary +pq thread summary # Current thread(s) summary +``` + +Example output: + +``` +Profile Summary [ts-Fo → ts-Fu] (851ms, 100% CPU) + +Top Threads: + t-93 (Renderer): 36.5% active, 63.5% waiting + t-0 (GeckoMain): 42.2% waiting, 34.3% active + +Hot Functions: + 16.3% dxgi.dll!CDXGISwapChain::PresentImpl + 7.7% WaitForFrameGPUQuery + 4.1% atidxx64.dll (AMD driver) + +Activity: + 91,300 Layout operations (30K style flushes) + 17 Composite frames (avg: 12.7ms) + 3 CSS transitions (200ms each) +``` + +--- + +## Medium Priority (Nice to Have) + +### 14. CPU Activity Timestamps Inline ✅ + +Timestamp names show actual times inline: + +```bash +pq profile info + +CPU activity over time: +- 100% for 390.6ms: [ts-6 → ts-7] (2.701s - 3.092s) +- 100% for 255.3ms: [ts-8 → ts-9] (3.102s - 3.357s) +- 100% for 851.1ms: [ts-Fo → ts-Fu] (9.453s - 10.305s) +``` + +### 15. Enhanced Zoom Output ✅ + +Zoom output shows duration, depth, and marker context: + +```bash +pq zoom push ts-i,ts-M +Pushed view range: ts-i → ts-M (6.991s - 10.558s, duration 3.567s) + ts-i: Start of CPU spike #3 (100% CPU sustained) + ts-M: End of marker m-143 (Composite frame) + Zoom depth: 2/5 (use "pq zoom pop" to go back) +``` + +### 16. Range Comparison ❌ + +**Proposed:** + +```bash +pq compare ts-6,ts-7 vs ts-8,ts-9 # Compare two ranges + +# Example output: +Comparison: [ts-6,ts-7] (391ms) vs [ts-8,ts-9] (255ms) + +CPU Activity: + Range 1: 100% CPU (390.6ms) Range 2: 100% CPU (255.3ms) + +Top Functions: + Range 1 Range 2 + 42.2% ZwWaitForAlertByThreadId 45.1% ZwWaitForAlertByThreadId (+2.9%) + 16.3% CDXGISwapChain::Present 18.7% CDXGISwapChain::Present (+2.4%) +``` + +### 17. Wait/Idle Analysis ❌ + +**Proposed:** + +```bash +pq thread waits # Show all wait operations +pq thread waits --min-duration 10ms # Significant waits only +pq thread waits --summary # Aggregate stats +``` + +### 18. Frame-Level Analysis ❌ + +**Proposed:** + +```bash +pq thread frames # List paint/composite frames +pq thread frames --slow # Frames >16ms (jank) +pq frame info 72 # Details about frame #72 +``` + +### 19. Stack-Level Inspection ❌ + +**Proposed:** + +```bash +pq thread stacks # Show heaviest individual stacks +pq thread stacks --limit 5 # Top 5 heaviest +``` + +### 20. Split Sample Command ⚠️ + +**Proposed:** + +```bash +pq thread samples-top [--limit N] # Just top functions +pq thread samples-tree [--max-depth N] # Just call tree +pq thread samples # All views (backward compat) +``` + +Note: `samples-bottom-up` is already a separate command (item #7). + +### 21. Cross-Thread Context ❌ + +**Proposed:** + +```bash +pq profile info --in-zoom # Show top threads/CPU in current zoom range +pq marker related m-158 # Show markers on other threads at same time +``` + +Example: + +``` +$ pq marker related m-168 +Marker m-168: Reflow (interruptible) at 3717465.724ms + +Related markers (±10ms window): + t-93 (Renderer): + m-5432 [3717465.2ms] Composite #72 (started 0.5ms before) + m-5433 [3717467.1ms] Texture uploads (during reflow) +``` + +### 22. Filter Provenance ❌ + +**Problem:** Output shows "50 markers (filtered from 258060)" but doesn't explain how filtering reduced the count + +Was it zoom? Search filter? Duration filter? Limit? Users can't tell what contributed to the reduction. + +**Proposed:** Show filter provenance chain: + +``` +50 markers shown (zoom: 258060 → 91300, filters: 91300 → 1200, limit: 1200 → 50) +``` + +Or more compact: + +``` +50 markers (from 258060: zoom→91300, filters→1200, limit→50) +``` + +This helps users understand whether they're missing important data due to filters or just seeing everything that matches their criteria. + +--- + +## Low Priority (Polish) + +### 23. Frequency Analysis Terminology ✅ + +Marker output uses clear terminology: + +``` +Frequency Analysis: + Image Paint: 29081.3 markers/sec (interval: min=4µs, avg=36µs, max=468µs) +``` + +The first number is frequency (markers/sec), and the min/avg/max values are intervals (time gaps between markers). + +### 24. Export/Save ❌ + +**Proposed:** + +```bash +pq thread samples --output report.txt # Save to file +pq thread markers --json > markers.json # JSON already works +pq session export investigation.pqsession # Save entire session state +``` + +### 25. Color Output ❌ + +**Proposed:** Color-code percentages, durations, and handles for easier scanning + +### 26. Progress Indicators ❌ + +**Proposed:** Show progress for operations >1s: + +``` +$ pq load large-profile.json.gz +Loading profile... 45% (123MB/273MB) +``` + +### 27. Sparklines/Histograms ❌ + +**Proposed:** ASCII sparklines for temporal distribution: + +``` +Markers in thread t-0: + Reflow: 534 markers ▁▂▃▅▇█▇▅▃▂▁ (peak: ts-Fo → ts-Fu) + Paint: 127 markers ▃▃▂▂▅▅▇█▃▂▁ (peak: ts-r → ts-s) +``` + +### 28. Smart Function Name Display ❌ + +**Enhancements:** + +- Allow `--name-width N` to control truncation +- Show ellipsis `...` when truncated +- For very long names, show start + end + +### 29. Auto-Suggest Next Steps ❌ + +**Proposed:** After commands, suggest related actions: + +``` +$ pq marker info m-168 +Marker m-168: Reflow (interruptible) - 907µs +... + +💡 Next steps: + pq marker stack m-168 # View call stack + pq zoom push m-168 # Zoom to this marker's time range + pq thread markers --search Reflow --min-duration 500 # Find similar markers +``` + +--- + +## Summary by Priority + +**Critical:** 1 item remaining (3 completed) + +- Smart range navigation - partially implemented (markers work, CPU spikes don't) + +**High:** 4 items remaining (5 completed) + +- Relative handle references +- Sample output filtering - partially implemented +- Inline thread selection +- Module-level grouping +- Profile summary command + +**Medium:** 7 items remaining (2 completed) + +- Range comparison +- Wait/idle analysis +- Frame-level analysis +- Stack-level inspection +- Split sample command - partially implemented +- Cross-thread context +- Filter provenance + +**Low:** 6 items remaining (1 completed) + +- Export/save +- Color output +- Progress indicators +- Sparklines/histograms +- Smart function name display +- Auto-suggest next steps + +--- + +## Core Features Already Implemented + +- **Marker support** with rich filtering (`--search`, `--category`, `--min-duration`, `--max-duration`, `--has-stack`, `--limit`, `--group-by`, `--auto-group`) +- **Function handles** (`function expand`, `function info`) +- **Smart function name truncation** (120 char limit, tree-based parsing) +- **Zoom range management** (`zoom push`, `zoom pop`, `zoom clear`, `status`) +- **Library/module names** in function display +- **Timestamp names + readable times** +- **Deep call trees** (Regular and Bottom-up/Inverted) +- **Persistent context display** in all command outputs diff --git a/scripts/build-profile-query-cli.mjs b/scripts/build-profile-query-cli.mjs new file mode 100644 index 0000000000..43d51413de --- /dev/null +++ b/scripts/build-profile-query-cli.mjs @@ -0,0 +1,30 @@ +/* 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/. */ +import esbuild from 'esbuild'; +import { execSync } from 'child_process'; +import { nodeBaseConfig } from './lib/esbuild-configs.mjs'; + +const BUILD_HASH = Date.now().toString(36); + +const profileQueryCliConfig = { + ...nodeBaseConfig, + entryPoints: ['src/profile-query-cli/index.ts'], + outfile: 'src/profile-query-cli/dist/pq.js', + minify: true, + banner: { + js: '#!/usr/bin/env node\n\n// Polyfill browser globals for Node.js\nglobalThis.self = globalThis;', + }, + define: { + __BUILD_HASH__: JSON.stringify(BUILD_HASH), + }, + external: [...nodeBaseConfig.external, 'gecko-profiler-demangle'], +}; + +async function build() { + await esbuild.build(profileQueryCliConfig); + execSync('chmod +x src/profile-query-cli/dist/pq.js'); + console.log('✅ Profile-query-cli build completed'); +} + +build().catch(console.error); diff --git a/scripts/build-profile-query.mjs b/scripts/build-profile-query.mjs new file mode 100644 index 0000000000..4427913ecd --- /dev/null +++ b/scripts/build-profile-query.mjs @@ -0,0 +1,19 @@ +/* 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/. */ +import esbuild from 'esbuild'; +import { nodeBaseConfig } from './lib/esbuild-configs.mjs'; + +const profileQueryConfig = { + ...nodeBaseConfig, + entryPoints: ['src/profile-query/index.ts'], + outfile: 'dist/profile-query.js', + external: [...nodeBaseConfig.external, 'gecko-profiler-demangle'], +}; + +async function build() { + await esbuild.build(profileQueryConfig); + console.log('✅ Profile-query build completed'); +} + +build().catch(console.error); diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 57495bec52..b955aaf2ac 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -394,6 +394,10 @@ export class CallTree { this._weightType = weightType; } + getTotal(): number { + return this._rootTotalSummary; + } + getRoots() { return this._roots; } diff --git a/src/profile-query-cli/.npmignore b/src/profile-query-cli/.npmignore new file mode 100644 index 0000000000..0c37eee2a6 --- /dev/null +++ b/src/profile-query-cli/.npmignore @@ -0,0 +1,32 @@ +# Source files - only publish the built dist/pq.js +*.ts +webpack.config.js + +# Build artifacts (except dist/pq.js which is in "files") +dist/* +!dist/pq.js + +# Development files +node_modules/ +*.log +*.tmp +.DS_Store + +# Git files +.git/ +.gitignore + +# Editor configs +.vscode/ +.idea/ +*.swp +*.swo + +# Test files +*.test.ts +*.test.js +__tests__/ +tests/ + +# CI/CD +.github/ diff --git a/src/profile-query-cli/README.md b/src/profile-query-cli/README.md new file mode 100644 index 0000000000..a4dd881a1b --- /dev/null +++ b/src/profile-query-cli/README.md @@ -0,0 +1,302 @@ +# Profile Query CLI (`pq`) + +A command-line interface for querying Firefox Profiler profiles with persistent daemon sessions. + +## Architecture + +**Two-process model:** + +- **Daemon process**: Long-running background process that loads a profile via `ProfileQuerier` and keeps it in memory +- **Client process**: Short-lived process that sends commands to the daemon and prints results + +**IPC:** Unix domain sockets with line-delimited JSON messages + +**Session storage:** `~/.pq/` (or `$PQ_SESSION_DIR` for development) + +## Commands + +**Note:** On machines with `pq` in PATH, you can use `pq` instead of `./dist/pq.js` for shorter commands. + +```bash +# Build the CLI +yarn build-profile-query-cli + +# Basic usage +pq load # Start daemon and load profile +pq profile info # Print profile summary +pq thread info # Print detailed thread information +pq thread select # Select a thread (e.g., t-0, t-1) +pq thread samples # Show call tree and top functions +pq function expand # Show full untruncated function name (e.g., f-1) +pq function info # Show detailed function information +pq zoom push # Push a zoom range (e.g., 2.7,3.1 or ts-g,ts-G or m-158) +pq zoom pop # Pop the most recent zoom range +pq status # Show session status (selected thread, zoom ranges) +pq stop # Stop daemon +pq list-sessions # List all running daemon sessions + +# Multiple sessions +pq load --session +pq profile info --session +pq stop --session + +# Thread selection +pq thread select t-93 # Select thread t-93 +pq thread samples # View samples for selected thread +pq thread info --thread t-0 # View info for specific thread without selecting +``` + +## Project Structure + +``` +src/profile-query-cli/ +├── index.ts # CLI entry point, argument parsing, command routing +├── client.ts # Client logic: spawn daemon, send commands via socket +├── daemon.ts # Daemon logic: load profile, listen on socket, handle commands +├── session.ts # Session file management, socket paths, validation +├── protocol.ts # TypeScript types for IPC messages +├── webpack.config.js # Build config with shebang and Node.js polyfills +├── package.json # npm distribution metadata (dependencies defined in root) +└── tests/ # CLI integration tests +``` + +## Build & Distribution + +This package uses a **bundled distribution approach**: + +- **Source code**: Lives in `src/profile-query-cli/` within the firefox-devtools/profiler monorepo +- **Dependencies**: Defined in the root `package.json` (react, redux, protobufjs, etc.) +- **Build process**: Webpack bundles and minifies everything into a single ~640KB `dist/pq.js` file (~187KB gzipped) with zero runtime dependencies +- **Published artifact**: Just the `dist/pq.js` executable is published to npm as `@firefox-profiler/pq` +- **Package.json**: Contains only npm metadata - it does NOT list dependencies since they're pre-bundled + +This means: + +- Users who install via npm get a self-contained binary that just works +- Developers working on the CLI use the root package.json dependencies +- The `package.json` in this directory is for npm publishing only, not for development + +To publish: + +```bash +# From repository root +yarn build-profile-query-cli +cd src/profile-query-cli +npm publish +``` + +## Session Management + +**Session directory:** `~/.pq/` or `$PQ_SESSION_DIR` + +**Files per session:** + +``` +~/.pq/ +├── current # Symlink to current session socket +├── .sock # Unix domain socket for IPC +├── .json # Session metadata (PID, profile path, timestamps) +└── .log # Daemon logs (kept for debugging) +``` + +**Session metadata example:** + +```json +{ + "id": "abc123xyz", + "socketPath": "/Users/user/.pq/abc123xyz.sock", + "logPath": "/Users/user/.pq/abc123xyz.log", + "pid": 12345, + "profilePath": "/path/to/profile.json", + "createdAt": "2025-10-31T10:00:00.000Z" +} +``` + +## Development Workflow + +**Environment variable isolation:** + +```bash +export PQ_SESSION_DIR="./.pq-dev" # Use local directory instead of ~/.pq +pq load profile.json # or: ./dist/pq.js load profile.json +``` + +All test scripts automatically set `PQ_SESSION_DIR="./.pq-dev"` to avoid polluting global state. + +**Build:** + +```bash +yarn build-profile-query-cli # Creates ./dist/pq.js, global `pq` forwards to this +``` + +**Unit tests:** + +```bash +yarn test profile-query +``` + +**CLI integration tests:** + +```bash +yarn test:cli +``` + +## Implementation Details + +**Daemon startup (client.ts):** + +1. Spawn detached Node.js process with `--daemon` flag +2. Poll every 50ms for session readiness (max 10 seconds) +3. Check: session ID exists → metadata exists → session validates +4. Return immediately when ready + +**IPC protocol (protocol.ts):** + +```typescript +// Client → Daemon +type ClientMessage = + | { type: 'command'; command: ClientCommand } + | { type: 'shutdown' } + | { type: 'status' }; + +type ClientCommand = + | { command: 'profile'; subcommand: 'info' | 'threads' } + | { + command: 'thread'; + subcommand: 'info' | 'select' | 'samples'; + thread?: string; + } + | { command: 'zoom'; subcommand: 'push' | 'pop'; range?: string } + | { command: 'status' }; +// ... and more + +// Daemon → Client +type ServerResponse = + | { type: 'success'; result: string } + | { type: 'error'; error: string } + | { type: 'loading' } + | { type: 'ready' }; +``` + +**Session validation (session.ts):** + +- Check PID is running (`process.kill(pid, 0)`) +- Check socket file exists +- Auto-cleanup stale sessions + +**Symlinks:** + +- `current` symlink uses relative path (`sessionId.sock`) +- Resolved to absolute in `getCurrentSocketPath()` when needed + +## Current State + +**Implemented:** + +- ✅ Persistent daemon with profile loading +- ✅ Unix socket IPC +- ✅ Multiple concurrent sessions +- ✅ Session management (current session, explicit session IDs) +- ✅ Environment variable isolation (`PQ_SESSION_DIR`) +- ✅ Manual test scripts +- ✅ `profile info` command (shows profile name, platform, threads, CPU activity) +- ✅ `thread info` command (shows thread details, CPU activity over time) +- ✅ `thread select` command (selects a thread for subsequent queries) +- ✅ `thread samples` command (call tree, top functions, inverted tree, heaviest stack) +- ✅ `zoom push` and `zoom pop` commands (time range filtering with multiple format support, including marker handles) +- ✅ `status` command (shows selected thread and zoom range stack) +- ✅ `list-sessions` command (shows all running sessions) + +- ✅ `function expand` command (shows full untruncated function name) +- ✅ `function info` command (shows detailed function metadata) + +**Partially implemented (parsed and wired up but not functional):** + +- ⚠️ `profile threads` - throws "unimplemented" in daemon +- ⚠️ `thread markers` - throws "unimplemented" in daemon +- ⚠️ `thread functions` - throws "unimplemented" in daemon +- ⚠️ `marker info` and `marker select` - throws "unimplemented" in daemon +- ⚠️ `sample info` and `sample select` - throws "unimplemented" in daemon +- ⚠️ `function select` - throws "unimplemented" in daemon + +## Build Configuration + +**Key webpack settings:** + +- `target: 'node'` - Node.js output +- `stats: 'errors-warnings'` - Quiet builds +- `BannerPlugin` - Adds `#!/usr/bin/env node` shebang +- `BannerPlugin` - Adds `globalThis.self = globalThis` polyfill for browser globals +- `optimization.minimize: false` - Keep readable stack traces +- Postbuild: `chmod +x dist/pq.js` + +## Adding New Commands + +To add a new command, you need to modify **4 files** (client.ts doesn't need changes as it generically forwards commands): + +### Step 1: Define the command type in `protocol.ts` + +Add your command to the `ClientCommand` type union: + +```typescript +export type ClientCommand = + | { command: 'profile'; subcommand: 'info' | 'threads' } + | { command: 'marker'; subcommand: 'info'; marker?: string } // Add new command + | ... +``` + +### Step 2: Parse CLI arguments in `index.ts` + +Add a case to the command switch statement: + +```typescript +case 'marker': { + const subcommand = argv._[1] ?? 'info'; + const marker = argv.marker; // Read from --marker flag + if (subcommand === 'info' || subcommand === 'select') { + const result = await sendCommand( + SESSION_DIR, + { command: 'marker', subcommand, marker }, + argv.session + ); + console.log(result); + } else { + console.error(`Error: Unknown command ${command} ${subcommand}`); + process.exit(1); + } + break; +} +``` + +**Note:** client.ts doesn't need changes - it generically forwards all commands via `sendCommand()`. + +### Step 3: Handle the command in `daemon.ts` + +Add a case to `processCommand()`: + +```typescript +case 'marker': + switch (command.subcommand) { + case 'info': + return this.querier!.markerInfo(command.marker); + case 'select': + return this.querier!.markerSelect(command.marker); + default: + throw assertExhaustiveCheck(command); + } +``` + +### Step 4: Implement ProfileQuerier methods in `src/profile-query/index.ts` + +```typescript +async markerInfo(markerHandle?: string): Promise { + // Implementation + return formatMarkerInfo(this._store, this._threadMap, markerHandle); +} +``` + +### Step 5: Update documentation + +- Add the command to the help text in `index.ts` (the `printUsage()` function) +- Add the command to the "Commands" section of this README +- Add the command to the "Current State" section as implemented diff --git a/src/profile-query-cli/client.ts b/src/profile-query-cli/client.ts new file mode 100644 index 0000000000..c1a5614725 --- /dev/null +++ b/src/profile-query-cli/client.ts @@ -0,0 +1,287 @@ +/** + * Client for communicating with the pq daemon. + */ + +import * as net from 'net'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as child_process from 'child_process'; +import type { + ClientCommand, + ClientMessage, + ServerResponse, + CommandResult, +} from './protocol'; +import { + cleanupSession, + generateSessionId, + getCurrentSessionId, + getCurrentSocketPath, + getSocketPath, + loadSessionMetadata, + validateSession, +} from './session'; +import { BUILD_HASH } from './constants'; + +/** + * Send a message to the daemon and return the raw response. + */ +async function sendRawMessage( + sessionDir: string, + message: ClientMessage, + sessionId?: string +): Promise { + const resolvedSessionId = sessionId || getCurrentSessionId(sessionDir); + + if (!resolvedSessionId) { + throw new Error('No active session. Run "pq load " first.'); + } + + // Validate the session + if (!validateSession(sessionDir, resolvedSessionId)) { + cleanupSession(sessionDir, resolvedSessionId); + throw new Error( + `Session ${resolvedSessionId} is not running or is invalid.` + ); + } + + // Check build hash matches + const metadata = loadSessionMetadata(sessionDir, resolvedSessionId); + if (metadata && metadata.buildHash !== BUILD_HASH) { + cleanupSession(sessionDir, resolvedSessionId); + throw new Error( + `Session ${resolvedSessionId} was built with a different version (daemon: ${metadata.buildHash}, client: ${BUILD_HASH}). The daemon has been stopped. Please run "pq load " again.` + ); + } + + const socketPath = sessionId + ? getSocketPath(sessionDir, sessionId) + : getCurrentSocketPath(sessionDir); + + if (!socketPath || !fs.existsSync(socketPath)) { + throw new Error(`Socket not found for session ${resolvedSessionId}`); + } + + return new Promise((resolve, reject) => { + const socket = net.connect(socketPath); + let buffer = ''; + + socket.on('connect', () => { + // Send the message + socket.write(JSON.stringify(message) + '\n'); + }); + + socket.on('data', (data) => { + buffer += data.toString(); + + // Look for complete response (newline-delimited JSON) + const newlineIndex = buffer.indexOf('\n'); + if (newlineIndex !== -1) { + const line = buffer.substring(0, newlineIndex); + try { + const response = JSON.parse(line) as ServerResponse; + socket.end(); + resolve(response); + } catch (error) { + reject(new Error(`Failed to parse response: ${error}`)); + } + } + }); + + socket.on('error', (error) => { + reject(new Error(`Socket error: ${error.message}`)); + }); + + socket.on('timeout', () => { + socket.destroy(); + reject(new Error('Connection timeout')); + }); + + socket.setTimeout(30000); // 30 second timeout + }); +} + +/** + * Send a message to the daemon and return the result. + * Only works for messages that return success responses. + * Result can be either a string (legacy) or a structured CommandResult. + */ +export async function sendMessage( + sessionDir: string, + message: ClientMessage, + sessionId?: string +): Promise { + const response = await sendRawMessage(sessionDir, message, sessionId); + + if (response.type === 'success') { + return response.result; + } else if (response.type === 'error') { + throw new Error(response.error); + } else { + throw new Error(`Unexpected response type: ${response.type}`); + } +} + +/** + * Send a status check to the daemon and return the response. + */ +async function sendStatusMessage( + sessionDir: string, + sessionId?: string +): Promise { + return sendRawMessage(sessionDir, { type: 'status' }, sessionId); +} + +/** + * Send a command to the daemon. + * Result can be either a string (legacy) or a structured CommandResult. + */ +export async function sendCommand( + sessionDir: string, + command: ClientCommand, + sessionId?: string +): Promise { + return sendMessage(sessionDir, { type: 'command', command }, sessionId); +} + +/** + * Start a new daemon for the given profile. + * Uses a two-phase approach: + * 1. Wait for daemon to be validated (short 500ms timeout) + * 2. Wait for profile to load via status checks (longer 60s timeout) + */ +export async function startNewDaemon( + sessionDir: string, + profilePath: string, + sessionId?: string +): Promise { + // Check if this is a URL + const isUrl = + profilePath.startsWith('http://') || profilePath.startsWith('https://'); + + // Resolve the absolute path (only for file paths, not URLs) + const absolutePath = isUrl ? profilePath : path.resolve(profilePath); + + // Check if file exists (skip this check for URLs) + if (!isUrl && !fs.existsSync(absolutePath)) { + throw new Error(`Profile file not found: ${absolutePath}`); + } + + // Generate a session ID upfront if not provided, so we know exactly which + // session to wait for (avoids race condition with existing sessions) + const targetSessionId = sessionId || generateSessionId(); + + // Get the path to the current script (pq.js) + const scriptPath = process.argv[1]; + + // Spawn the daemon process (detached from parent) + const child = child_process.spawn( + process.execPath, // node + [scriptPath, '--daemon', absolutePath, '--session', targetSessionId], + { + detached: true, + stdio: 'ignore', // Don't pipe stdin/stdout/stderr + env: { ...process.env, PQ_SESSION_DIR: sessionDir }, // Pass sessionDir via env + } + ); + + // Unref so parent can exit + child.unref(); + + // Phase 1: Wait for daemon to be validated (short timeout) + const daemonStartMaxAttempts = 10; // 10 * 50ms = 500ms + let attempts = 0; + + while (attempts < daemonStartMaxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 50)); + attempts++; + + // Validate the session (checks metadata exists, process running, socket exists) + if (validateSession(sessionDir, targetSessionId)) { + // Daemon is validated and running + break; + } + } + + // Check if daemon started successfully after polling + if (!validateSession(sessionDir, targetSessionId)) { + throw new Error( + `Failed to start daemon: session not validated after ${daemonStartMaxAttempts * 50}ms` + ); + } + + // Phase 2: Wait for profile to load by checking status (longer timeout) + const profileLoadMaxAttempts = 600; // 600 * 100ms = 60 seconds + attempts = 0; + + while (attempts < profileLoadMaxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 100)); + attempts++; + + try { + const response = await sendStatusMessage(sessionDir, targetSessionId); + + switch (response.type) { + case 'ready': + // Profile loaded successfully + return targetSessionId; + + case 'loading': + // Still loading, keep waiting + continue; + + case 'error': + // Profile load failed, fail immediately + throw new Error(response.error); + + default: + // Unexpected response type + throw new Error( + `Unexpected response type: ${(response as any).type}` + ); + } + } catch (error) { + // Socket connection errors - daemon might still be setting up + // Keep retrying unless it's an explicit error response + if ( + error instanceof Error && + error.message.startsWith('Profile load failed') + ) { + throw error; + } + continue; + } + } + + // If we got here, profile load timed out + throw new Error( + `Profile load timeout after ${profileLoadMaxAttempts * 100}ms` + ); +} + +/** + * Stop a running daemon. + */ +export async function stopDaemon( + sessionDir: string, + sessionId?: string +): Promise { + const resolvedSessionId = sessionId || getCurrentSessionId(sessionDir); + + if (!resolvedSessionId) { + throw new Error('No active session to stop.'); + } + + // Send shutdown command + try { + await sendMessage(sessionDir, { type: 'shutdown' }, resolvedSessionId); + } catch (error) { + // If the daemon is already dead, that's fine + console.error(`Note: ${error}`); + } + + // Wait a bit for cleanup + await new Promise((resolve) => setTimeout(resolve, 500)); + + console.log(`Session ${resolvedSessionId} stopped`); +} diff --git a/src/profile-query-cli/constants.ts b/src/profile-query-cli/constants.ts new file mode 100644 index 0000000000..3dbb385995 --- /dev/null +++ b/src/profile-query-cli/constants.ts @@ -0,0 +1,12 @@ +/** + * Build-time constants injected by webpack. + */ + +// This global is defined by webpack.DefinePlugin +declare const __BUILD_HASH__: string; + +/** + * Unique hash for this build, used to detect version mismatches + * between client and daemon. + */ +export const BUILD_HASH = __BUILD_HASH__; diff --git a/src/profile-query-cli/daemon.ts b/src/profile-query-cli/daemon.ts new file mode 100644 index 0000000000..acc6f1a156 --- /dev/null +++ b/src/profile-query-cli/daemon.ts @@ -0,0 +1,412 @@ +/** + * Daemon process for pq. + * Loads a profile and listens for commands on a Unix socket. + */ + +import * as net from 'net'; +import * as fs from 'fs'; +import { ProfileQuerier } from '../profile-query'; +import type { + ClientCommand, + ClientMessage, + ServerResponse, + SessionMetadata, + CommandResult, +} from './protocol'; +import { + generateSessionId, + getSocketPath, + getLogPath, + saveSessionMetadata, + setCurrentSession, + cleanupSession, + ensureSessionDir, +} from './session'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import { BUILD_HASH } from './constants'; + +export class Daemon { + private querier: ProfileQuerier | null = null; + private server: net.Server | null = null; + private sessionDir: string; + private sessionId: string; + private socketPath: string; + private logPath: string; + private logStream: fs.WriteStream; + private profilePath: string; + private loadingProfile: boolean = false; + private profileLoadError: Error | null = null; + + constructor(sessionDir: string, profilePath: string, sessionId?: string) { + this.sessionDir = sessionDir; + this.profilePath = profilePath; + this.sessionId = sessionId || generateSessionId(); + this.socketPath = getSocketPath(sessionDir, this.sessionId); + this.logPath = getLogPath(sessionDir, this.sessionId); + this.logStream = fs.createWriteStream(this.logPath, { flags: 'a' }); + + // Redirect console to log file + this.redirectConsole(); + + // Handle shutdown signals + process.on('SIGINT', () => this.shutdown('SIGINT')); + process.on('SIGTERM', () => this.shutdown('SIGTERM')); + } + + private redirectConsole(): void { + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + const originalConsoleWarn = console.warn; + + console.log = (...args: any[]) => { + const message = args.map((arg) => String(arg)).join(' '); + this.logStream.write(`[LOG] ${new Date().toISOString()} ${message}\n`); + originalConsoleLog(...args); + }; + + console.error = (...args: any[]) => { + const message = args.map((arg) => String(arg)).join(' '); + this.logStream.write(`[ERROR] ${new Date().toISOString()} ${message}\n`); + originalConsoleError(...args); + }; + + console.warn = (...args: any[]) => { + const message = args.map((arg) => String(arg)).join(' '); + this.logStream.write(`[WARN] ${new Date().toISOString()} ${message}\n`); + originalConsoleWarn(...args); + }; + } + + async start(): Promise { + try { + console.log(`Starting daemon for session ${this.sessionId}`); + console.log(`Profile path: ${this.profilePath}`); + console.log(`Socket path: ${this.socketPath}`); + console.log(`Log path: ${this.logPath}`); + + // Ensure session directory exists + ensureSessionDir(this.sessionDir); + + // Create Unix socket server BEFORE loading the profile + this.server = net.createServer((socket) => this.handleConnection(socket)); + + // Remove stale socket if it exists + if (fs.existsSync(this.socketPath)) { + fs.unlinkSync(this.socketPath); + } + + this.server.listen(this.socketPath, () => { + console.log(`Daemon listening on ${this.socketPath}`); + + // Save session metadata immediately + const metadata: SessionMetadata = { + id: this.sessionId, + socketPath: this.socketPath, + logPath: this.logPath, + pid: process.pid, + profilePath: this.profilePath, + createdAt: new Date().toISOString(), + buildHash: BUILD_HASH, + }; + saveSessionMetadata(this.sessionDir, metadata); + setCurrentSession(this.sessionDir, this.sessionId); + + console.log('Daemon ready (socket listening)'); + + // Start loading the profile in the background + this.loadProfileAsync(); + }); + + this.server.on('error', (error) => { + console.error(`Server error: ${error}`); + this.shutdown('error'); + }); + } catch (error) { + console.error(`Failed to start daemon: ${error}`); + process.exit(1); + } + } + + private async loadProfileAsync(): Promise { + this.loadingProfile = true; + try { + console.log('Loading profile...'); + this.querier = await ProfileQuerier.load(this.profilePath); + console.log('Profile loaded successfully'); + this.loadingProfile = false; + } catch (error) { + console.error(`Failed to load profile: ${error}`); + this.profileLoadError = + error instanceof Error ? error : new Error(String(error)); + this.loadingProfile = false; + } + } + + private handleConnection(socket: net.Socket): void { + console.log('Client connected'); + + let buffer = ''; + + socket.on('data', (data) => { + buffer += data.toString(); + + // Process complete lines + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.substring(0, newlineIndex); + buffer = buffer.substring(newlineIndex + 1); + + if (line.trim()) { + this.handleMessage(line, socket); + } + } + }); + + socket.on('error', (error) => { + console.error(`Socket error: ${error}`); + }); + + socket.on('end', () => { + console.log('Client disconnected'); + }); + } + + private handleMessage(line: string, socket: net.Socket): void { + try { + const message = JSON.parse(line) as ClientMessage; + console.log(`Received message: ${message.type}`); + + this.processMessage(message) + .then((response) => { + socket.write(JSON.stringify(response) + '\n'); + }) + .catch((error) => { + const errorResponse: ServerResponse = { + type: 'error', + error: String(error), + }; + socket.write(JSON.stringify(errorResponse) + '\n'); + }); + } catch (error) { + console.error(`Failed to parse message: ${error}`); + const errorResponse: ServerResponse = { + type: 'error', + error: `Failed to parse message: ${error}`, + }; + socket.write(JSON.stringify(errorResponse) + '\n'); + } + } + + private async processMessage( + message: ClientMessage + ): Promise { + switch (message.type) { + case 'status': { + // Return current daemon state + if (this.profileLoadError) { + return { + type: 'error', + error: `Profile load failed: ${this.profileLoadError.message}`, + }; + } + if (this.loadingProfile) { + return { type: 'loading' }; + } + if (this.querier) { + return { type: 'ready' }; + } + // Shouldn't happen, but handle gracefully + return { + type: 'error', + error: 'Profile not loaded', + }; + } + + case 'shutdown': { + console.log('Shutdown command received'); + // Send response before shutting down + const response: ServerResponse = { + type: 'success', + result: 'Shutting down', + }; + setImmediate(() => this.shutdown('command')); + return response; + } + + case 'command': { + // Commands require profile to be loaded + if (this.profileLoadError) { + return { + type: 'error', + error: `Profile load failed: ${this.profileLoadError.message}`, + }; + } + if (this.loadingProfile) { + return { + type: 'error', + error: 'Profile still loading, try again shortly', + }; + } + if (!this.querier) { + return { + type: 'error', + error: 'Profile not loaded', + }; + } + + const result = await this.processCommand(message.command); + return { + type: 'success', + result, + }; + } + + default: { + return { + type: 'error', + error: `Unknown message type: ${(message as any).type}`, + }; + } + } + } + + private async processCommand( + command: ClientCommand + ): Promise { + switch (command.command) { + case 'profile': + switch (command.subcommand) { + case 'info': + return this.querier!.profileInfo(); + case 'threads': + throw new Error('unimplemented'); + default: + throw assertExhaustiveCheck(command); + } + case 'thread': + switch (command.subcommand) { + case 'info': + return this.querier!.threadInfo(command.thread); + case 'select': + if (!command.thread) { + throw new Error('thread handle required for thread select'); + } + return this.querier!.threadSelect(command.thread); + case 'samples': + return this.querier!.threadSamples(command.thread); + case 'samples-top-down': + return this.querier!.threadSamplesTopDown( + command.thread, + command.callTreeOptions + ); + case 'samples-bottom-up': + return this.querier!.threadSamplesBottomUp( + command.thread, + command.callTreeOptions + ); + case 'markers': + return this.querier!.threadMarkers( + command.thread, + command.markerFilters + ); + case 'functions': + return this.querier!.threadFunctions( + command.thread, + command.functionFilters + ); + default: + throw assertExhaustiveCheck(command); + } + case 'marker': + switch (command.subcommand) { + case 'info': + if (!command.marker) { + throw new Error('marker handle required for marker info'); + } + return this.querier!.markerInfo(command.marker); + case 'stack': + if (!command.marker) { + throw new Error('marker handle required for marker stack'); + } + return this.querier!.markerStack(command.marker); + case 'select': + throw new Error('unimplemented'); + default: + throw assertExhaustiveCheck(command); + } + case 'sample': + switch (command.subcommand) { + case 'info': + throw new Error('unimplemented'); + case 'select': + throw new Error('unimplemented'); + default: + throw assertExhaustiveCheck(command); + } + case 'function': + switch (command.subcommand) { + case 'info': + if (!command.function) { + throw new Error('function handle required for function info'); + } + return this.querier!.functionInfo(command.function); + case 'expand': + if (!command.function) { + throw new Error('function handle required for function expand'); + } + return this.querier!.functionExpand(command.function); + case 'select': + throw new Error('unimplemented'); + default: + throw assertExhaustiveCheck(command); + } + case 'zoom': + switch (command.subcommand) { + case 'push': + if (!command.range) { + throw new Error('range parameter is required for zoom push'); + } + return this.querier!.pushViewRange(command.range); + case 'pop': + return this.querier!.popViewRange(); + case 'clear': + return this.querier!.clearViewRange(); + default: + throw assertExhaustiveCheck(command); + } + case 'status': + return this.querier!.getStatus(); + default: + throw assertExhaustiveCheck(command); + } + } + + private shutdown(reason: string): void { + console.log(`Shutting down daemon (reason: ${reason})`); + + if (this.server) { + this.server.close(); + } + + cleanupSession(this.sessionDir, this.sessionId); + + if (this.logStream) { + this.logStream.end(); + } + + console.log('Daemon stopped'); + process.exit(0); + } +} + +/** + * Start the daemon (called from CLI). + */ +export async function startDaemon( + sessionDir: string, + profilePath: string, + sessionId?: string +): Promise { + const daemon = new Daemon(sessionDir, profilePath, sessionId); + await daemon.start(); +} diff --git a/src/profile-query-cli/formatters.ts b/src/profile-query-cli/formatters.ts new file mode 100644 index 0000000000..76c693b193 --- /dev/null +++ b/src/profile-query-cli/formatters.ts @@ -0,0 +1,859 @@ +/** + * Text formatters for CommandResult types. + * These functions convert structured JSON results into human-readable text output. + */ + +import type { + StatusResult, + SessionContext, + WithContext, + FunctionExpandResult, + FunctionInfoResult, + ViewRangeResult, + ThreadInfoResult, + MarkerStackResult, + MarkerInfoResult, + ProfileInfoResult, + ThreadSamplesResult, + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + ThreadMarkersResult, + ThreadFunctionsResult, + MarkerGroupData, + CallTreeNode, +} from './protocol'; +import { truncateFunctionName } from '../profile-query/function-list'; + +/** + * Format a SessionContext as a compact header line. + * Shows current thread selection, zoom range, and full profile duration. + */ +export function formatContextHeader(context: SessionContext): string { + // Thread info + let threadInfo = 'No thread selected'; + if (context.selectedThreadHandle && context.selectedThreads.length > 0) { + if (context.selectedThreads.length === 1) { + const thread = context.selectedThreads[0]; + threadInfo = `${context.selectedThreadHandle} (${thread.name})`; + } else { + const names = context.selectedThreads + .map((t: { name: string }) => t.name) + .join(', '); + threadInfo = `${context.selectedThreadHandle} (${names})`; + } + } + + // View range info + const rootDuration = context.rootRange.end - context.rootRange.start; + const formatDuration = (ms: number): string => { + if (ms < 1000) { + return `${ms.toFixed(1)}ms`; + } + return `${(ms / 1000).toFixed(2)}s`; + }; + + let viewInfo = 'Full profile'; + if (context.currentViewRange) { + const range = context.currentViewRange; + const rangeDuration = range.end - range.start; + viewInfo = `${range.startName}→${range.endName} (${formatDuration(rangeDuration)})`; + } + + const fullInfo = formatDuration(rootDuration); + + return `[Thread: ${threadInfo} | View: ${viewInfo} | Full: ${fullInfo}]`; +} + +/** + * Format a StatusResult as plain text. + */ +export function formatStatusResult(result: StatusResult): string { + let threadInfo = 'No thread selected'; + if (result.selectedThreadHandle && result.selectedThreads.length > 0) { + if (result.selectedThreads.length === 1) { + const thread = result.selectedThreads[0]; + threadInfo = `${result.selectedThreadHandle} (${thread.name})`; + } else { + const names = result.selectedThreads.map((t) => t.name).join(', '); + threadInfo = `${result.selectedThreadHandle} (${names})`; + } + } + + let rangesInfo = 'Full profile'; + if (result.viewRanges.length > 0) { + const rangeStrs = result.viewRanges.map((range) => { + return `${range.startName} to ${range.endName}`; + }); + rangesInfo = rangeStrs.join(' > '); + } + + return `\ +Session Status: + Selected thread: ${threadInfo} + View range: ${rangesInfo}`; +} + +/** + * Format a FunctionExpandResult as plain text. + */ +export function formatFunctionExpandResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + return `${contextHeader} + +Function ${result.functionHandle} (thread ${result.threadHandle}): +${result.fullName}`; +} + +/** + * Format a FunctionInfoResult as plain text. + */ +export function formatFunctionInfoResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + let output = `${contextHeader} + +Function ${result.functionHandle}: + Thread: ${result.threadHandle} (${result.threadName}) + Full name: ${result.fullName} + Short name: ${result.name} + Is JS: ${result.isJS} + Relevant for JS: ${result.relevantForJS}`; + + if (result.resource) { + output += `\n Resource: ${result.resource.name}`; + } + + if (result.library) { + output += `\n Library: ${result.library.name}`; + output += `\n Library path: ${result.library.path}`; + if (result.library.debugName) { + output += `\n Debug name: ${result.library.debugName}`; + } + if (result.library.debugPath) { + output += `\n Debug path: ${result.library.debugPath}`; + } + if (result.library.breakpadId) { + output += `\n Breakpad ID: ${result.library.breakpadId}`; + } + } + + return output; +} + +/** + * Format a ViewRangeResult as plain text. + */ +export function formatViewRangeResult(result: ViewRangeResult): string { + // Start with the basic message + let output = result.message; + + // For 'push' action, add enhanced information if available + if (result.action === 'push' && result.duration !== undefined) { + output += ` (duration: ${formatDuration(result.duration)})`; + + // If this is a marker zoom, show marker details + if (result.markerInfo) { + output += `\n Zoomed to: Marker ${result.markerInfo.markerHandle} - ${result.markerInfo.markerName}`; + output += `\n Thread: ${result.markerInfo.threadHandle} (${result.markerInfo.threadName})`; + } + + // Show zoom depth if available + if (result.zoomDepth !== undefined) { + output += `\n Zoom depth: ${result.zoomDepth}${result.zoomDepth > 1 ? ' (use "pq zoom pop" to go back)' : ''}`; + } + } + + return output; +} + +/** + * Format a ThreadInfoResult as plain text. + */ +export function formatThreadInfoResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + const endedAtStr = result.endedAtName || 'still alive at end of recording'; + + let output = `${contextHeader} + +Name: ${result.friendlyName} +Created at: ${result.createdAtName} +Ended at: ${endedAtStr} + +This thread contains ${result.sampleCount} samples and ${result.markerCount} markers. + +CPU activity over time:`; + + if (result.cpuActivity && result.cpuActivity.length > 0) { + for (const activity of result.cpuActivity) { + const indent = ' '.repeat(activity.depthLevel); + const percentage = Math.round( + (activity.cpuMs / (activity.endTime - activity.startTime)) * 100 + ); + output += `\n${indent}- ${percentage}% for ${activity.cpuMs.toFixed(1)}ms: [${activity.startTimeName} → ${activity.endTimeName}] (${activity.startTimeStr} - ${activity.endTimeStr})`; + } + } else { + output += '\nNo significant activity.'; + } + + return output; +} + +/** + * Format a MarkerStackResult as plain text. + */ +export function formatMarkerStackResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + let output = `${contextHeader} + +Stack trace for marker ${result.markerHandle}: ${result.markerName}\n`; + output += `Thread: ${result.threadHandle} (${result.friendlyThreadName})`; + + if (!result.stack || result.stack.frames.length === 0) { + return output + '\n\n(This marker has no stack trace)'; + } + + if (result.stack.capturedAt !== undefined) { + output += `\nCaptured at: ${result.stack.capturedAt.toFixed(3)}ms\n`; + } + + for (let i = 0; i < result.stack.frames.length; i++) { + const frame = result.stack.frames[i]; + output += `\n [${i + 1}] ${frame.nameWithLibrary}`; + } + + if (result.stack.truncated) { + output += '\n ... (truncated)'; + } + + return output; +} + +/** + * Format a MarkerInfoResult as plain text. + */ +export function formatMarkerInfoResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + let output = `${contextHeader} + +Marker ${result.markerHandle}: ${result.name}`; + if (result.tooltipLabel) { + output += ` - ${result.tooltipLabel}`; + } + output += '\n\n'; + + // Basic info + output += `Type: ${result.markerType ?? 'None'}\n`; + output += `Category: ${result.category.name}\n`; + + // Time and duration + const startStr = result.start.toFixed(3); + if (result.end !== null) { + const endStr = result.end.toFixed(3); + const durationMs = result.duration!; + let durationStr: string; + if (durationMs < 1) { + durationStr = `${(durationMs * 1000).toFixed(1)}µs`; + } else if (durationMs < 1000) { + durationStr = `${durationMs.toFixed(2)}ms`; + } else { + durationStr = `${(durationMs / 1000).toFixed(3)}s`; + } + output += `Time: ${startStr}ms - ${endStr}ms (${durationStr})\n`; + } else { + output += `Time: ${startStr}ms (instant)\n`; + } + + output += `Thread: ${result.threadHandle} (${result.friendlyThreadName})\n`; + + // Marker data fields + if (result.fields && result.fields.length > 0) { + output += '\nFields:\n'; + for (const field of result.fields) { + output += ` ${field.label}: ${field.formattedValue}\n`; + } + } + + // Schema description + if (result.schema?.description) { + output += '\nDescription:\n'; + output += ` ${result.schema.description}\n`; + } + + // Stack trace (truncated to 20 frames) + if (result.stack && result.stack.frames.length > 0) { + output += '\nStack trace:\n'; + if (result.stack.capturedAt !== undefined) { + output += ` Captured at: ${result.stack.capturedAt.toFixed(3)}ms\n`; + } + + for (let i = 0; i < result.stack.frames.length; i++) { + const frame = result.stack.frames[i]; + output += ` [${i + 1}] ${frame.nameWithLibrary}\n`; + } + + if (result.stack.truncated) { + output += `\nUse 'pq marker stack ${result.markerHandle}' for the full stack trace.\n`; + } + } + + return output; +} + +/** + * Format a ProfileInfoResult as plain text. + */ +export function formatProfileInfoResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + let output = `${contextHeader} + +Name: ${result.name}\n`; + output += `Platform: ${result.platform}\n\n`; + output += `This profile contains ${result.threadCount} threads across ${result.processCount} processes.\n`; + + if (result.processes.length === 0) { + output += '\n(CPU time information not available)'; + return output; + } + + output += '\nTop processes and threads by CPU usage:\n'; + + for (const process of result.processes) { + // Format process timing information + let timingInfo = ''; + if (process.startTime !== undefined && process.startTimeName) { + if (process.endTime !== null && process.endTimeName !== null) { + timingInfo = ` [${process.startTimeName} → ${process.endTimeName}]`; + } else { + timingInfo = ` [${process.startTimeName} → end]`; + } + } + + output += ` p-${process.processIndex}: ${process.name} [pid ${process.pid}]${timingInfo} - ${process.cpuMs.toFixed(3)}ms\n`; + + for (const thread of process.threads) { + output += ` ${thread.threadHandle}: ${thread.name} - ${thread.cpuMs.toFixed(3)}ms\n`; + } + + if (process.remainingThreads) { + output += ` + ${process.remainingThreads.count} more threads with combined CPU time ${process.remainingThreads.combinedCpuMs.toFixed(3)}ms and max CPU time ${process.remainingThreads.maxCpuMs.toFixed(3)}ms\n`; + } + } + + if (result.remainingProcesses) { + output += ` + ${result.remainingProcesses.count} more processes with combined CPU time ${result.remainingProcesses.combinedCpuMs.toFixed(3)}ms and max CPU time ${result.remainingProcesses.maxCpuMs.toFixed(3)}ms\n`; + } + + output += '\nCPU activity over time:\n'; + + if (result.cpuActivity && result.cpuActivity.length > 0) { + for (const activity of result.cpuActivity) { + const indent = ' '.repeat(activity.depthLevel); + const percentage = Math.round( + (activity.cpuMs / (activity.endTime - activity.startTime)) * 100 + ); + output += `${indent}- ${percentage}% for ${activity.cpuMs.toFixed(1)}ms: [${activity.startTimeName} → ${activity.endTimeName}] (${activity.startTimeStr} - ${activity.endTimeStr})\n`; + } + } else { + output += 'No significant activity.\n'; + } + + return output; +} + +/** + * Helper function to format a call tree node recursively. + * + * This formatter uses a "stack fragment" approach for single-child chains: + * - Root-level nodes always indent their children with tree symbols + * - Single-child continuations are rendered without tree symbols (as stack fragments) + * - Only nodes with multiple children use tree symbols to show branching + */ +function formatCallTreeNode( + node: CallTreeNode, + baseIndent: string, + useTreeSymbol: boolean, + isLastSibling: boolean, + depth: number, + lines: string[] +): void { + const totalPct = node.totalPercentage.toFixed(1); + const selfPct = node.selfPercentage.toFixed(1); + const displayName = truncateFunctionName(node.nameWithLibrary, 120); + + // Build the line prefix + let linePrefix: string; + if (useTreeSymbol) { + const symbol = isLastSibling ? '└─ ' : '├─ '; + linePrefix = baseIndent + symbol; + } else { + linePrefix = baseIndent; + } + + // Add function handle prefix if available + const handlePrefix = node.functionHandle ? `${node.functionHandle}. ` : ''; + + lines.push( + `${linePrefix}${handlePrefix}${displayName} [total: ${totalPct}%, self: ${selfPct}%]` + ); + + // Handle children and truncation + const hasChildren = node.children && node.children.length > 0; + const hasTruncatedChildren = node.childrenTruncated; + + if (hasChildren || hasTruncatedChildren) { + // Calculate the base indent for children + let childBaseIndent: string; + if (useTreeSymbol) { + // We used a tree symbol, so children need appropriate spine continuation + const spine = isLastSibling ? ' ' : '│ '; + childBaseIndent = baseIndent + spine; + } else { + // We didn't use a tree symbol (stack fragment), children keep the same base indent + childBaseIndent = baseIndent; + } + + if (hasChildren) { + const hasMultipleChildren = + node.children.length > 1 || !!hasTruncatedChildren; + + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + const isLast = i === node.children.length - 1 && !hasTruncatedChildren; + + // Children use tree symbols if: + // - There are multiple children (branching), OR + // - We're at root level (depth 0) - root children always get tree symbols + const childUsesTreeSymbol = hasMultipleChildren || depth === 0; + + formatCallTreeNode( + child, + childBaseIndent, + childUsesTreeSymbol, + isLast, + depth + 1, + lines + ); + } + } + + // Show combined elision info if children were omitted or depth limit reached + // Combine both types of elision into a single marker + if (hasTruncatedChildren) { + const truncPrefix = childBaseIndent + '└─ '; + const truncInfo = node.childrenTruncated!; + const combinedPct = truncInfo.combinedPercentage.toFixed(1); + const maxPct = truncInfo.maxPercentage.toFixed(1); + lines.push( + `${truncPrefix}... (${truncInfo.count} more children: combined ${combinedPct}%, max ${maxPct}%)` + ); + } + } +} + +/** + * Helper function to format a call tree. + */ +function formatCallTree(tree: CallTreeNode, title: string): string { + const lines: string[] = [`${title} Call Tree:`]; + + // The root node is virtual, so format its children + if (tree.children && tree.children.length > 0) { + for (let i = 0; i < tree.children.length; i++) { + const child = tree.children[i]; + const isLast = i === tree.children.length - 1; + // Root-level nodes don't use tree symbols (they are the starting points) + formatCallTreeNode(child, '', false, isLast, 0, lines); + } + } + + return lines.join('\n'); +} + +/** + * Format a ThreadSamplesResult as plain text. + */ +export function formatThreadSamplesResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + let output = `${contextHeader} + +Thread: ${result.friendlyThreadName}\n\n`; + + // Top functions by total time + output += 'Top Functions (by total time):\n'; + output += + ' (For a call tree starting from these functions, use: pq thread samples-top-down)\n\n'; + for (const func of result.topFunctionsByTotal) { + const totalCount = Math.round(func.totalSamples); + const totalPct = func.totalPercentage.toFixed(1); + const displayName = truncateFunctionName(func.nameWithLibrary, 120); + output += ` ${func.functionHandle}. ${displayName} - total: ${totalCount} (${totalPct}%)\n`; + } + + output += '\n'; + + // Top functions by self time + output += 'Top Functions (by self time):\n'; + output += + ' (For a call tree showing what calls these functions, use: pq thread samples-bottom-up)\n\n'; + for (const func of result.topFunctionsBySelf) { + const selfCount = Math.round(func.selfSamples); + const selfPct = func.selfPercentage.toFixed(1); + const displayName = truncateFunctionName(func.nameWithLibrary, 120); + output += ` ${func.functionHandle}. ${displayName} - self: ${selfCount} (${selfPct}%)\n`; + } + + output += '\n'; + + // Heaviest stack + const stack = result.heaviestStack; + output += `Heaviest stack (${stack.selfSamples.toFixed(1)} samples, ${stack.frameCount} frames):\n`; + + if (stack.frames.length === 0) { + output += ' (empty)\n'; + } else if (stack.frameCount <= 200) { + // Show all frames + for (let i = 0; i < stack.frames.length; i++) { + const frame = stack.frames[i]; + const displayName = truncateFunctionName(frame.nameWithLibrary, 120); + const totalCount = Math.round(frame.totalSamples); + const totalPct = frame.totalPercentage.toFixed(1); + const selfCount = Math.round(frame.selfSamples); + const selfPct = frame.selfPercentage.toFixed(1); + output += ` ${i + 1}. ${displayName} - total: ${totalCount} (${totalPct}%), self: ${selfCount} (${selfPct}%)\n`; + } + } else { + // Show first 100 + for (let i = 0; i < 100; i++) { + const frame = stack.frames[i]; + const displayName = truncateFunctionName(frame.nameWithLibrary, 120); + const totalCount = Math.round(frame.totalSamples); + const totalPct = frame.totalPercentage.toFixed(1); + const selfCount = Math.round(frame.selfSamples); + const selfPct = frame.selfPercentage.toFixed(1); + output += ` ${i + 1}. ${displayName} - total: ${totalCount} (${totalPct}%), self: ${selfCount} (${selfPct}%)\n`; + } + + // Show placeholder for skipped frames + const skippedCount = stack.frameCount - 200; + output += ` ... (${skippedCount} frames skipped)\n`; + + // Show last 100 + for (let i = stack.frameCount - 100; i < stack.frameCount; i++) { + const frame = stack.frames[i]; + const displayName = truncateFunctionName(frame.nameWithLibrary, 120); + const totalCount = Math.round(frame.totalSamples); + const totalPct = frame.totalPercentage.toFixed(1); + const selfCount = Math.round(frame.selfSamples); + const selfPct = frame.selfPercentage.toFixed(1); + output += ` ${i + 1}. ${displayName} - total: ${totalCount} (${totalPct}%), self: ${selfCount} (${selfPct}%)\n`; + } + } + + return output; +} + +/** + * Format a ThreadSamplesTopDownResult as plain text. + */ +export function formatThreadSamplesTopDownResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + let output = `${contextHeader} + +Thread: ${result.friendlyThreadName}\n\n`; + + // Top-down call tree + output += formatCallTree(result.regularCallTree, 'Top-Down'); + + return output; +} + +/** + * Format a ThreadSamplesBottomUpResult as plain text. + */ +export function formatThreadSamplesBottomUpResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + let output = `${contextHeader} + +Thread: ${result.friendlyThreadName}\n\n`; + + // Bottom-up call tree (inverted tree shows callers) + if (result.invertedCallTree) { + output += formatCallTree(result.invertedCallTree, 'Bottom-Up'); + } else { + output += 'Bottom-Up Call Tree:\n (unable to create bottom-up tree)'; + } + + return output; +} + +/** + * Format a ThreadMarkersResult as plain text. + */ +export function formatThreadMarkersResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + const lines: string[] = [contextHeader, '']; + + // Check if filters are active + const hasFilters = result.filters !== undefined; + const filterSuffix = + hasFilters && result.filteredMarkerCount !== result.totalMarkerCount + ? ` (filtered from ${result.totalMarkerCount})` + : ''; + + lines.push( + `Markers in thread ${result.threadHandle} (${result.friendlyThreadName}) — ${result.filteredMarkerCount} markers${filterSuffix}` + ); + lines.push('Legend: ✓ = has stack trace, ✗ = no stack trace\n'); + + if (result.filteredMarkerCount === 0) { + if (hasFilters) { + lines.push('No markers match the specified filters.'); + } else { + lines.push('No markers in this thread.'); + } + return lines.join('\n'); + } + + // Handle custom grouping if present + if (result.customGroups && result.customGroups.length > 0) { + formatMarkerGroupsForDisplay(lines, result.customGroups, 0); + } else { + // Default aggregation by marker name + lines.push('By Name (top 15):'); + const topTypes = result.byType.slice(0, 15); + for (const stats of topTypes) { + let line = ` ${stats.markerName.padEnd(25)} ${stats.count.toString().padStart(5)} markers`; + + if (stats.durationStats) { + const { min, avg, max } = stats.durationStats; + line += ` (interval: min=${formatDuration(min)}, avg=${formatDuration(avg)}, max=${formatDuration(max)})`; + } else { + line += ' (instant)'; + } + + lines.push(line); + + // Show top markers with handles (for easy inspection) + if (!stats.subGroups && stats.topMarkers.length > 0) { + const handleList = stats.topMarkers + .slice(0, 3) + .map((m) => { + const stackIndicator = m.hasStack ? '✓' : '✗'; + const handleWithIndicator = `${m.handle} ${stackIndicator}`; + if (m.duration !== undefined) { + return `${handleWithIndicator} (${formatDuration(m.duration)})`; + } + return handleWithIndicator; + }) + .join(', '); + lines.push(` Examples: ${handleList}`); + } + + // Show sub-groups if present (from auto-grouping) + if (stats.subGroups && stats.subGroups.length > 0) { + if (stats.subGroupKey) { + lines.push(` Grouped by ${stats.subGroupKey}:`); + } + formatMarkerGroupsForDisplay(lines, stats.subGroups, 2); + } + } + + if (result.byType.length > 15) { + lines.push(` ... (${result.byType.length - 15} more marker names)`); + } + + lines.push(''); + + // Aggregate by category + lines.push('By Category:'); + for (const stats of result.byCategory) { + lines.push( + ` ${stats.categoryName.padEnd(25)} ${stats.count.toString().padStart(5)} markers (${stats.percentage.toFixed(1)}%)` + ); + } + + lines.push(''); + + // Frequency analysis for top markers + lines.push('Frequency Analysis:'); + const topRateTypes = result.byType + .filter((s) => s.rateStats && s.rateStats.markersPerSecond > 0) + .slice(0, 5); + + for (const stats of topRateTypes) { + if (!stats.rateStats) continue; + const { markersPerSecond, minGap, avgGap, maxGap } = stats.rateStats; + lines.push( + ` ${stats.markerName}: ${markersPerSecond.toFixed(1)} markers/sec (interval: min=${formatDuration(minGap)}, avg=${formatDuration(avgGap)}, max=${formatDuration(maxGap)})` + ); + } + + lines.push(''); + } + + lines.push( + 'Use --search , --category , --min-duration , --max-duration , --has-stack, --limit , --group-by , or --auto-group to filter/group markers, or m- handles to inspect individual markers.' + ); + + return lines.join('\n'); +} + +/** + * Helper function to format marker groups hierarchically. + */ +function formatMarkerGroupsForDisplay( + lines: string[], + groups: MarkerGroupData[], + baseIndent: number +): void { + for (const group of groups) { + const indent = ' '.repeat(baseIndent); + let line = `${indent}${group.groupName}: ${group.count} markers`; + + if (group.durationStats) { + const { avg, max } = group.durationStats; + line += ` (avg=${formatDuration(avg)}, max=${formatDuration(max)})`; + } + + lines.push(line); + + // Show top markers if no sub-groups + if (!group.subGroups && group.topMarkers.length > 0) { + const handleList = group.topMarkers + .slice(0, 3) + .map((m) => { + const stackIndicator = m.hasStack ? '✓' : '✗'; + const handleWithIndicator = `${m.handle} ${stackIndicator}`; + if (m.duration !== undefined) { + return `${handleWithIndicator} (${formatDuration(m.duration)})`; + } + return handleWithIndicator; + }) + .join(', '); + lines.push(`${indent} Examples: ${handleList}`); + } + + // Recursively format sub-groups + if (group.subGroups && group.subGroups.length > 0) { + formatMarkerGroupsForDisplay(lines, group.subGroups, baseIndent + 1); + } + } +} + +/** + * Helper function to format duration in milliseconds. + */ +function formatDuration(ms: number): string { + if (ms < 1) { + return `${(ms * 1000).toFixed(0)}µs`; + } else if (ms < 1000) { + return `${ms.toFixed(2)}ms`; + } + return `${(ms / 1000).toFixed(2)}s`; +} + +/** + * Format a ThreadFunctionsResult as plain text. + */ +export function formatThreadFunctionsResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + const lines: string[] = [contextHeader, '']; + + // Check if filters are active + const hasFilters = result.filters !== undefined; + const filterSuffix = + hasFilters && result.filteredFunctionCount !== result.totalFunctionCount + ? ` (filtered from ${result.totalFunctionCount})` + : ''; + + lines.push( + `Functions in thread ${result.threadHandle} (${result.friendlyThreadName}) — ${result.filteredFunctionCount} functions${filterSuffix}\n` + ); + + if (result.filteredFunctionCount === 0) { + if (hasFilters) { + lines.push('No functions match the specified filters.'); + } else { + lines.push('No functions in this thread.'); + } + return lines.join('\n'); + } + + // Show active filters if any + if (hasFilters && result.filters) { + const filterParts: string[] = []; + if (result.filters.searchString) { + filterParts.push(`search: "${result.filters.searchString}"`); + } + if (result.filters.minSelf !== undefined) { + filterParts.push(`min-self: ${result.filters.minSelf}%`); + } + if (result.filters.limit !== undefined) { + filterParts.push(`limit: ${result.filters.limit}`); + } + if (filterParts.length > 0) { + lines.push(`Filters: ${filterParts.join(', ')}\n`); + } + } + + // List functions sorted by self time + lines.push('Functions (by self time):'); + for (const func of result.functions) { + const selfCount = Math.round(func.selfSamples); + const totalCount = Math.round(func.totalSamples); + const displayName = truncateFunctionName(func.nameWithLibrary, 120); + + // Format percentages: show dual percentages when zoomed + let selfPctStr: string; + let totalPctStr: string; + if ( + func.fullSelfPercentage !== undefined && + func.fullTotalPercentage !== undefined + ) { + // Zoomed: show both view and full percentages + selfPctStr = `${func.selfPercentage.toFixed(1)}% of view, ${func.fullSelfPercentage.toFixed(1)}% of full`; + totalPctStr = `${func.totalPercentage.toFixed(1)}% of view, ${func.fullTotalPercentage.toFixed(1)}% of full`; + } else { + // Not zoomed: show single percentage + selfPctStr = `${func.selfPercentage.toFixed(1)}%`; + totalPctStr = `${func.totalPercentage.toFixed(1)}%`; + } + + lines.push( + ` ${func.functionHandle}. ${displayName} - self: ${selfCount} (${selfPctStr}), total: ${totalCount} (${totalPctStr})` + ); + } + + if (result.filteredFunctionCount > result.functions.length) { + const omittedCount = result.filteredFunctionCount - result.functions.length; + lines.push(`\n ... (${omittedCount} more functions omitted)`); + } + + lines.push(''); + lines.push( + 'Use --search , --min-self , or --limit to filter functions, or f- handles to inspect individual functions.' + ); + + return lines.join('\n'); +} diff --git a/src/profile-query-cli/index.ts b/src/profile-query-cli/index.ts new file mode 100644 index 0000000000..1e36595e7f --- /dev/null +++ b/src/profile-query-cli/index.ts @@ -0,0 +1,664 @@ +/** + * CLI entry point for pq (Profile Querier). + * + * Usage: + * pq load [--session ] Start a new daemon and load a profile + * pq profile info [--session ] Print profile summary + * pq thread info [--thread ] Print thread information + * pq thread samples [--thread ] Show thread call tree and top functions + * pq stop [--session ] [--all] Stop the daemon + * pq list-sessions List all running sessions + * + * Build: + * yarn build-profile-query-cli + * + * Run: + * pq (if pq is in PATH) + * ./dist/pq.js (direct invocation) + * + * Helper scripts: + * ./bin/pq-test Quick smoke test + * ./bin/pq-test-multi Test multiple concurrent sessions + * ./bin/pq-status Show session status + * ./bin/pq-clean Clean up sessions (--logs to also remove logs) + */ + +import * as path from 'path'; +import * as os from 'os'; +import minimist from 'minimist'; +import { startDaemon } from './daemon'; +import { sendCommand, startNewDaemon, stopDaemon } from './client'; +import { cleanupSession, listSessions, validateSession } from './session'; +import type { + MarkerFilterOptions, + FunctionFilterOptions, + CommandResult, + CallTreeCollectionOptions, + CallTreeScoringStrategy, +} from './protocol'; +import { + formatStatusResult, + formatFunctionExpandResult, + formatFunctionInfoResult, + formatViewRangeResult, + formatThreadInfoResult, + formatMarkerStackResult, + formatMarkerInfoResult, + formatProfileInfoResult, + formatThreadSamplesResult, + formatThreadSamplesTopDownResult, + formatThreadSamplesBottomUpResult, + formatThreadMarkersResult, + formatThreadFunctionsResult, +} from './formatters'; + +// Read session directory from environment (only place this is read) +const SESSION_DIR = + process.env.PQ_SESSION_DIR || path.join(os.homedir(), '.pq'); + +interface Args { + _: string[]; + session?: string; + daemon?: boolean; + help?: boolean; + h?: boolean; + json?: boolean; + 'max-lines'?: number; + scoring?: string; +} + +function printUsage(): void { + console.log(`Usage: pq [options] + +Commands: + load Load a profile and start a daemon session + profile info Print profile summary (processes, threads, CPU activity) + thread info Print detailed thread information + thread select Select a thread (e.g., t-0, t-1) + thread samples Show hot functions list for a thread + thread samples-top-down Show top-down call tree (where CPU time is spent) + thread samples-bottom-up Show bottom-up call tree (what calls hot functions) + thread markers List markers with aggregated statistics + thread functions List all functions with CPU percentages + marker info Show detailed marker information (e.g., m-1234) + marker stack Show full stack trace for a marker (e.g., m-1234) + function expand Show full untruncated function name (e.g., f-1) + function info Show detailed function information + zoom push Push a zoom range (e.g., 2.7,3.1 or ts-g,ts-G or m-158) + zoom pop Pop the most recent zoom range + zoom clear Clear all zoom ranges (return to full profile) + status Show session status (selected thread, zoom ranges) + stop Stop the daemon session + list-sessions List all running daemon sessions + +Options: + --session Use a specific session (default: current session) + --thread Specify thread by handle (e.g., t-0, t-1) + --marker Specify marker by handle (e.g., m-1, m-2) + --function Specify function by handle (e.g., f-1, f-2) + --search Search/filter by substring (for 'thread markers' and 'thread functions') + --category Filter markers by category name (case-insensitive substring match) + --min-duration Filter markers by minimum duration in milliseconds + --max-duration Filter markers by maximum duration in milliseconds + --min-self Filter functions by minimum self time percentage (for 'thread functions') + --has-stack Filter to show only markers with stack traces + --limit Limit the number of results shown + --group-by Group markers by custom keys (e.g., "type,name" or "type,field:eventType") + --auto-group Automatically determine grouping based on field variance + --max-lines Maximum nodes in call tree (for 'samples-top-down'/'samples-bottom-up', default: 100) + --scoring Call tree scoring: exponential-0.95, exponential-0.9 (default), exponential-0.8, + harmonic-0.1, harmonic-0.5, harmonic-1.0, percentage-only + --json Output results as JSON (for use with jq, etc.) + --help, -h Show this help message + +Examples: + pq load profile.json.gz + pq profile info + pq thread info --thread t-0 + pq thread samples + pq thread markers + pq thread functions + pq thread functions --search Present + pq thread functions --min-self 1 + pq thread functions --limit 50 + pq thread markers --search DOMEvent + pq thread markers --category Graphics + pq thread markers --min-duration 10 + pq thread markers --max-duration 100 + pq thread markers --has-stack + pq thread markers --limit 1000 + pq thread markers --category Layout --min-duration 5 + pq thread markers --search Reflow --min-duration 5 --max-duration 50 + pq thread markers --has-stack --category Other --limit 500 + pq thread markers --group-by type,name + pq thread markers --group-by type,field:eventType + pq thread markers --auto-group + pq thread markers --search DOMEvent --group-by field:eventType + pq marker info m-1234 + pq marker stack m-1234 + pq function expand f-12 + pq function info f-12 + pq zoom push 2.7,3.1 + pq zoom push m-158 + pq zoom pop + pq zoom clear + pq status + pq stop + pq list-sessions + pq thread samples-top-down --max-lines 50 + pq thread samples-top-down --scoring exponential-0.8 + pq thread samples-bottom-up --max-lines 200 --scoring harmonic-1.0 +`); +} + +/** + * Format command result for output. + * If json flag is set, output JSON. Otherwise output as string. + */ +function formatOutput( + result: string | CommandResult, + jsonFlag: boolean +): string { + if (jsonFlag) { + if (typeof result === 'string') { + // Legacy string result - wrap in a simple JSON structure + return JSON.stringify({ type: 'text', result }, null, 2); + } + // Structured result - output as JSON + return JSON.stringify(result, null, 2); + } + + // Plain text output + if (typeof result === 'string') { + return result; + } + + // Format structured results as plain text + switch (result.type) { + case 'status': + return formatStatusResult(result); + case 'function-expand': + return formatFunctionExpandResult(result); + case 'function-info': + return formatFunctionInfoResult(result); + case 'view-range': + return formatViewRangeResult(result); + case 'thread-info': + return formatThreadInfoResult(result); + case 'marker-stack': + return formatMarkerStackResult(result); + case 'marker-info': + return formatMarkerInfoResult(result); + case 'profile-info': + return formatProfileInfoResult(result); + case 'thread-samples': + return formatThreadSamplesResult(result); + case 'thread-samples-top-down': + return formatThreadSamplesTopDownResult(result); + case 'thread-samples-bottom-up': + return formatThreadSamplesBottomUpResult(result); + case 'thread-markers': + return formatThreadMarkersResult(result); + case 'thread-functions': + return formatThreadFunctionsResult(result); + default: + // For types without formatters yet, fall back to JSON + return JSON.stringify(result, null, 2); + } +} + +async function main(): Promise { + const argv = minimist(process.argv.slice(2), { + string: [ + 'session', + 'thread', + 'marker', + 'sample', + 'function', + 'search', + 'min-duration', + 'max-duration', + 'min-self', + 'category', + 'limit', + 'group-by', + 'max-lines', + 'scoring', + ], + boolean: ['daemon', 'help', 'h', 'all', 'has-stack', 'auto-group', 'json'], + alias: { h: 'help' }, + }); + + // Check for help flag + if (argv.help || argv.h) { + printUsage(); + process.exit(0); + } + + // Internal flag: running as daemon + if (argv.daemon) { + const profilePath = argv._[0]; + if (!profilePath) { + console.error('Error: Profile path required for daemon mode'); + process.exit(1); + } + await startDaemon(SESSION_DIR, profilePath, argv.session); + return; + } + + // Parse command + const command = argv._[0]; + + if (!command) { + console.error('Error: No command specified\n'); + printUsage(); + process.exit(1); + } + + try { + switch (command) { + case 'help': { + printUsage(); + break; + } + + case 'load': { + const profilePath = argv._[1]; + if (!profilePath) { + console.error('Error: Profile path required for "load" command'); + console.error('Usage: pq load [--session ]'); + process.exit(1); + } + + console.log(`Loading profile from ${profilePath}...`); + const sessionId = await startNewDaemon( + SESSION_DIR, + profilePath, + argv.session + ); + console.log(`Session started: ${sessionId}`); + break; + } + + case 'profile': { + const subcommand = argv._[1] ?? 'info'; + if (subcommand === 'info' || subcommand === 'threads') { + const result = await sendCommand( + SESSION_DIR, + { command: 'profile', subcommand }, + argv.session + ); + console.log(formatOutput(result, argv.json || false)); + } else { + console.error(`Error: Unknown command ${command} ${subcommand}`); + process.exit(1); + } + break; + } + + case 'thread': { + const subcommand = argv._[1] ?? 'info'; + // For thread select, get the thread handle from argv._[2] if not provided via --thread flag + const thread = + argv.thread ?? (subcommand === 'select' ? argv._[2] : undefined); + + // Parse marker filter options if this is a markers command + let markerFilters: MarkerFilterOptions | undefined; + let functionFilters: FunctionFilterOptions | undefined; + + if (subcommand === 'markers') { + const hasSearch = !!argv.search; + const hasMinDuration = !!argv['min-duration']; + const hasMaxDuration = !!argv['max-duration']; + const hasCategory = !!argv.category; + const hasStack = argv['has-stack']; + const hasLimit = !!argv.limit; + const hasGroupBy = !!argv['group-by']; + const hasAutoGroup = argv['auto-group']; + + if ( + hasSearch || + hasMinDuration || + hasMaxDuration || + hasCategory || + hasStack || + hasLimit || + hasGroupBy || + hasAutoGroup + ) { + markerFilters = {}; + if (hasSearch) { + markerFilters.searchString = argv.search; + } + if (hasMinDuration) { + const minDuration = parseFloat(argv['min-duration']); + if (isNaN(minDuration) || minDuration < 0) { + console.error( + 'Error: --min-duration must be a positive number (in milliseconds)' + ); + process.exit(1); + } + markerFilters.minDuration = minDuration; + } + if (hasMaxDuration) { + const maxDuration = parseFloat(argv['max-duration']); + if (isNaN(maxDuration) || maxDuration < 0) { + console.error( + 'Error: --max-duration must be a positive number (in milliseconds)' + ); + process.exit(1); + } + markerFilters.maxDuration = maxDuration; + } + if (hasCategory) { + markerFilters.category = argv.category; + } + if (hasStack) { + markerFilters.hasStack = true; + } + if (hasLimit) { + const limit = parseInt(argv.limit, 10); + if (isNaN(limit) || limit <= 0) { + console.error('Error: --limit must be a positive integer'); + process.exit(1); + } + markerFilters.limit = limit; + } + if (hasGroupBy) { + markerFilters.groupBy = argv['group-by']; + } + if (hasAutoGroup) { + markerFilters.autoGroup = true; + } + } + } + + // Parse function filter options if this is a functions command + if (subcommand === 'functions') { + const hasSearch = !!argv.search; + const hasMinSelf = !!argv['min-self']; + const hasLimit = !!argv.limit; + + if (hasSearch || hasMinSelf || hasLimit) { + functionFilters = {}; + if (hasSearch) { + functionFilters.searchString = argv.search; + } + if (hasMinSelf) { + const minSelf = parseFloat(argv['min-self']); + if (isNaN(minSelf) || minSelf < 0 || minSelf > 100) { + console.error( + 'Error: --min-self must be a number between 0 and 100 (percentage)' + ); + process.exit(1); + } + functionFilters.minSelf = minSelf; + } + if (hasLimit) { + const limit = parseInt(argv.limit, 10); + if (isNaN(limit) || limit <= 0) { + console.error('Error: --limit must be a positive integer'); + process.exit(1); + } + functionFilters.limit = limit; + } + } + } + + // Parse call tree options for samples-top-down and samples-bottom-up + let callTreeOptions: CallTreeCollectionOptions | undefined; + if ( + subcommand === 'samples-top-down' || + subcommand === 'samples-bottom-up' + ) { + const hasMaxLines = !!argv['max-lines']; + const hasScoring = !!argv.scoring; + + if (hasMaxLines || hasScoring) { + callTreeOptions = {}; + if (hasMaxLines) { + const maxLines = parseInt(String(argv['max-lines']), 10); + if (isNaN(maxLines) || maxLines <= 0) { + console.error('Error: --max-lines must be a positive integer'); + process.exit(1); + } + callTreeOptions.maxNodes = maxLines; + } + if (hasScoring) { + const validStrategies = [ + 'exponential-0.95', + 'exponential-0.92', + 'exponential-0.9', + 'exponential-0.8', + 'harmonic-0.1', + 'harmonic-0.5', + 'harmonic-1.0', + 'percentage-only', + ]; + const scoringValue = argv.scoring; + if (!scoringValue || !validStrategies.includes(scoringValue)) { + console.error( + `Error: --scoring must be one of: ${validStrategies.join(', ')}` + ); + process.exit(1); + } + callTreeOptions.scoringStrategy = + scoringValue as CallTreeScoringStrategy; + } + } + } + + if ( + subcommand === 'info' || + subcommand === 'select' || + subcommand === 'samples' || + subcommand === 'samples-top-down' || + subcommand === 'samples-bottom-up' || + subcommand === 'markers' || + subcommand === 'functions' + ) { + const result = await sendCommand( + SESSION_DIR, + { + command: 'thread', + subcommand, + thread, + markerFilters, + functionFilters, + callTreeOptions, + }, + argv.session + ); + console.log(formatOutput(result, argv.json || false)); + } else { + console.error(`Error: Unknown command ${command} ${subcommand}`); + process.exit(1); + } + break; + } + + case 'marker': { + const subcommand = argv._[1] ?? 'info'; + // For marker commands, get the marker handle from argv._[2] if not provided via --marker flag + const marker = + argv.marker ?? + (subcommand === 'info' || + subcommand === 'select' || + subcommand === 'stack' + ? argv._[2] + : undefined); + if ( + subcommand === 'info' || + subcommand === 'select' || + subcommand === 'stack' + ) { + const result = await sendCommand( + SESSION_DIR, + { command: 'marker', subcommand, marker }, + argv.session + ); + console.log(formatOutput(result, argv.json || false)); + } else { + console.error(`Error: Unknown command ${command} ${subcommand}`); + process.exit(1); + } + break; + } + + case 'sample': { + const subcommand = argv._[1] ?? 'info'; + const sample = argv.sample; + if (subcommand === 'info' || subcommand === 'select') { + const result = await sendCommand( + SESSION_DIR, + { command: 'sample', subcommand, sample }, + argv.session + ); + console.log(formatOutput(result, argv.json || false)); + } else { + console.error(`Error: Unknown command ${command} ${subcommand}`); + process.exit(1); + } + break; + } + + case 'function': { + const subcommand = argv._[1] ?? 'info'; + // For function commands, get the function handle from argv._[2] if not provided via --function flag + const function_ = + argv.function ?? + (subcommand === 'info' || + subcommand === 'expand' || + subcommand === 'select' + ? argv._[2] + : undefined); + if ( + subcommand === 'info' || + subcommand === 'expand' || + subcommand === 'select' + ) { + const result = await sendCommand( + SESSION_DIR, + { command: 'function', subcommand, function: function_ }, + argv.session + ); + console.log(formatOutput(result, argv.json || false)); + } else { + console.error(`Error: Unknown command ${command} ${subcommand}`); + process.exit(1); + } + break; + } + + case 'zoom': { + const subcommand = argv._[1]; + if (!subcommand) { + console.error('Error: zoom command requires a subcommand'); + console.error('Usage: pq zoom [range]'); + process.exit(1); + } + if (subcommand === 'push') { + const range = argv._[2]; + if (!range) { + console.error('Error: zoom push requires a range argument'); + console.error('Usage: pq zoom push '); + console.error('Example: pq zoom push 2.7,3.1'); + process.exit(1); + } + const result = await sendCommand( + SESSION_DIR, + { command: 'zoom', subcommand: 'push', range }, + argv.session + ); + console.log(formatOutput(result, argv.json || false)); + } else if (subcommand === 'pop') { + const result = await sendCommand( + SESSION_DIR, + { command: 'zoom', subcommand: 'pop' }, + argv.session + ); + console.log(formatOutput(result, argv.json || false)); + } else if (subcommand === 'clear') { + const result = await sendCommand( + SESSION_DIR, + { command: 'zoom', subcommand: 'clear' }, + argv.session + ); + console.log(formatOutput(result, argv.json || false)); + } else { + console.error(`Error: Unknown command ${command} ${subcommand}`); + process.exit(1); + } + break; + } + + case 'status': { + const result = await sendCommand( + SESSION_DIR, + { command: 'status' }, + argv.session + ); + console.log(formatOutput(result, argv.json || false)); + break; + } + + case 'stop': { + if (argv.all) { + const sessionIds = listSessions(SESSION_DIR); + await Promise.all( + sessionIds.map((id) => stopDaemon(SESSION_DIR, id)) + ); + } else { + await stopDaemon(SESSION_DIR, argv.session); + } + break; + } + + case 'list-sessions': { + const sessionIds = listSessions(SESSION_DIR); + let numCleaned = 0; + const runningSessionMetadata = []; + for (const sessionId of sessionIds) { + const metadata = validateSession(SESSION_DIR, sessionId); + if (metadata === null) { + cleanupSession(SESSION_DIR, sessionId); + numCleaned++; + continue; + } + runningSessionMetadata.push(metadata); + } + + if (numCleaned !== 0) { + console.log(`Cleaned up ${numCleaned} stale sessions.`); + console.log(); + } + runningSessionMetadata.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + console.log(`Found ${runningSessionMetadata.length} running sessions:`); + for (const metadata of runningSessionMetadata) { + console.log( + `- ${metadata.id}, created at ${metadata.createdAt} [daemon pid: ${metadata.pid}]` + ); + } + + break; + } + + default: { + console.error(`Error: Unknown command "${command}"\n`); + printUsage(); + process.exit(1); + } + } + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } +} + +main().catch((error) => { + console.error(`Fatal error: ${error}`); + process.exit(1); +}); diff --git a/src/profile-query-cli/package.json b/src/profile-query-cli/package.json new file mode 100644 index 0000000000..dba0aecd0e --- /dev/null +++ b/src/profile-query-cli/package.json @@ -0,0 +1,35 @@ +{ + "_comment": "This is a distribution package for a pre-bundled CLI artifact. Build dependencies are defined in the root package.json. Run 'yarn build-profile-query-cli' from the repository root to build dist/pq.js.", + "name": "@firefox-profiler/pq", + "version": "0.1.0", + "description": "Command-line interface for querying Firefox Profiler profiles with persistent daemon sessions", + "main": "./dist/pq.js", + "bin": { + "pq": "./dist/pq.js" + }, + "files": [ + "dist/pq.js" + ], + "engines": { + "node": ">= 22 < 23" + }, + "keywords": [ + "profiler", + "firefox", + "performance", + "profiling", + "cli", + "performance-analysis" + ], + "author": "Mozilla DevTools", + "license": "MPL-2.0", + "repository": { + "type": "git", + "url": "https://github.com/firefox-devtools/profiler", + "directory": "src/profile-query-cli" + }, + "homepage": "https://profiler.firefox.com", + "bugs": { + "url": "https://github.com/firefox-devtools/profiler/issues" + } +} diff --git a/src/profile-query-cli/protocol.ts b/src/profile-query-cli/protocol.ts new file mode 100644 index 0000000000..cdf6e21c0b --- /dev/null +++ b/src/profile-query-cli/protocol.ts @@ -0,0 +1,128 @@ +/** + * Protocol for communication between pq client and daemon. + * Messages are sent as line-delimited JSON over Unix domain sockets. + */ + +// Re-export shared types from profile-query +export type { + MarkerFilterOptions, + FunctionFilterOptions, + SessionContext, + WithContext, + StatusResult, + FunctionExpandResult, + FunctionInfoResult, + ViewRangeResult, + ThreadInfoResult, + ThreadSamplesResult, + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + CallTreeNode, + CallTreeScoringStrategy, + ThreadMarkersResult, + ThreadFunctionsResult, + DurationStats, + RateStats, + MarkerGroupData, + MarkerInfoResult, + MarkerStackResult, + StackTraceData, + ProfileInfoResult, +} from '../profile-query/types'; +export type { CallTreeCollectionOptions } from '../profile-query/formatters/call-tree'; + +// Import types for use in type definitions +import type { + MarkerFilterOptions, + FunctionFilterOptions, + WithContext, + StatusResult, + FunctionExpandResult, + FunctionInfoResult, + ViewRangeResult, + ThreadInfoResult, + MarkerStackResult, + MarkerInfoResult, + ProfileInfoResult, + ThreadSamplesResult, + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + ThreadMarkersResult, + ThreadFunctionsResult, +} from '../profile-query/types'; +import type { CallTreeCollectionOptions } from '../profile-query/formatters/call-tree'; + +export type ClientMessage = + | { type: 'command'; command: ClientCommand } + | { type: 'shutdown' } + | { type: 'status' }; + +export type ClientCommand = + | { command: 'profile'; subcommand: 'info' | 'threads' } + | { + command: 'thread'; + subcommand: + | 'info' + | 'select' + | 'samples' + | 'samples-top-down' + | 'samples-bottom-up' + | 'markers' + | 'functions'; + thread?: string; + markerFilters?: MarkerFilterOptions; + functionFilters?: FunctionFilterOptions; + callTreeOptions?: CallTreeCollectionOptions; + } + | { + command: 'marker'; + subcommand: 'info' | 'select' | 'stack'; + marker?: string; + } + | { command: 'sample'; subcommand: 'info' | 'select'; sample?: string } + | { + command: 'function'; + subcommand: 'info' | 'select' | 'expand'; + function?: string; + } + | { + command: 'zoom'; + subcommand: 'push' | 'pop' | 'clear'; + range?: string; + } + | { command: 'status' }; + +export type ServerResponse = + | { type: 'success'; result: string | CommandResult } + | { type: 'error'; error: string } + | { type: 'loading' } + | { type: 'ready' }; + +/** + * CommandResult is a union of all possible structured result types. + * Commands can return either a string (legacy) or a structured result. + */ +export type CommandResult = + | StatusResult + | WithContext + | WithContext + | ViewRangeResult + | WithContext + | WithContext + | WithContext + | WithContext + | WithContext + | WithContext + | WithContext + | WithContext + | WithContext; + +export interface SessionMetadata { + id: string; + socketPath: string; + logPath: string; + pid: number; + profilePath: string; + createdAt: string; + buildHash: string; +} diff --git a/src/profile-query-cli/session.ts b/src/profile-query-cli/session.ts new file mode 100644 index 0000000000..a0a14c950c --- /dev/null +++ b/src/profile-query-cli/session.ts @@ -0,0 +1,202 @@ +/** + * Session management for pq daemon. + * Handles session files, socket paths, and current session tracking. + * + * All functions take an explicit sessionDir parameter for testability + * and to avoid global state. The CLI entry point reads PQ_SESSION_DIR + * once and passes it through. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { SessionMetadata } from './protocol'; + +/** + * Ensure the session directory exists. + */ +export function ensureSessionDir(sessionDir: string): void { + if (!fs.existsSync(sessionDir)) { + fs.mkdirSync(sessionDir, { recursive: true }); + } +} + +/** + * Generate a new session ID. + */ +export function generateSessionId(): string { + return Math.random().toString(36).substring(2, 15); +} + +/** + * Get the socket path for a session. + */ +export function getSocketPath(sessionDir: string, sessionId: string): string { + return path.join(sessionDir, `${sessionId}.sock`); +} + +/** + * Get the log path for a session. + */ +export function getLogPath(sessionDir: string, sessionId: string): string { + return path.join(sessionDir, `${sessionId}.log`); +} + +/** + * Get the metadata file path for a session. + */ +export function getMetadataPath(sessionDir: string, sessionId: string): string { + return path.join(sessionDir, `${sessionId}.json`); +} + +/** + * Save session metadata to disk. + */ +export function saveSessionMetadata( + sessionDir: string, + metadata: SessionMetadata +): void { + ensureSessionDir(sessionDir); + const metadataPath = getMetadataPath(sessionDir, metadata.id); + fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); +} + +/** + * Load session metadata from disk. + */ +export function loadSessionMetadata( + sessionDir: string, + sessionId: string +): SessionMetadata | null { + const metadataPath = getMetadataPath(sessionDir, sessionId); + if (!fs.existsSync(metadataPath)) { + return null; + } + try { + const data = fs.readFileSync(metadataPath, 'utf-8'); + return JSON.parse(data) as SessionMetadata; + } catch (_error) { + return null; + } +} + +/** + * Set the current session by writing to a text file. + */ +export function setCurrentSession(sessionDir: string, sessionId: string): void { + ensureSessionDir(sessionDir); + + const currentSessionFile = path.join(sessionDir, 'current.txt'); + fs.writeFileSync(currentSessionFile, sessionId, 'utf-8'); +} + +/** + * Get the current session ID by reading from a text file. + */ +export function getCurrentSessionId(sessionDir: string): string | null { + const currentSessionFile = path.join(sessionDir, 'current.txt'); + + if (!fs.existsSync(currentSessionFile)) { + return null; + } + + try { + return fs.readFileSync(currentSessionFile, 'utf-8').trim(); + } catch (_error) { + console.error(`Failed to read current session: ${_error}`); + return null; + } +} + +/** + * Get the socket path for the current session. + */ +export function getCurrentSocketPath(sessionDir: string): string | null { + const sessionId = getCurrentSessionId(sessionDir); + + if (!sessionId) { + return null; + } + + return getSocketPath(sessionDir, sessionId); +} + +/** + * Check if a process is running. + */ +export function isProcessRunning(pid: number): boolean { + try { + // Sending signal 0 checks if process exists without killing it + process.kill(pid, 0); + return true; + } catch (_error) { + return false; + } +} + +/** + * Clean up a session's files. + */ +export function cleanupSession(sessionDir: string, sessionId: string): void { + const socketPath = getSocketPath(sessionDir, sessionId); + const metadataPath = getMetadataPath(sessionDir, sessionId); + const currentSessionFile = path.join(sessionDir, 'current.txt'); + // Note: We intentionally don't delete the log file for debugging purposes + // const logPath = getLogPath(sessionDir, sessionId); + + // Remove socket file + if (fs.existsSync(socketPath)) { + fs.unlinkSync(socketPath); + } + + // Remove metadata file + if (fs.existsSync(metadataPath)) { + fs.unlinkSync(metadataPath); + } + + // Remove current session file if it points to this session + const currentSessionId = getCurrentSessionId(sessionDir); + if (currentSessionId === sessionId && fs.existsSync(currentSessionFile)) { + fs.unlinkSync(currentSessionFile); + } +} + +/** + * Validate that a session is healthy (process running, socket exists). + * If not, clean up stale files. + */ +export function validateSession( + sessionDir: string, + sessionId: string +): SessionMetadata | null { + const metadata = loadSessionMetadata(sessionDir, sessionId); + if (!metadata) { + return null; + } + + // Check if process is still running + if (!isProcessRunning(metadata.pid)) { + // console.error( + // `Session ${sessionId} daemon process with PID ${metadata.pid} not found. Cleaning up.` + // ); + return null; + } + + // Check if socket exists + if (!fs.existsSync(metadata.socketPath)) { + // console.error(`Session ${sessionId} socket not found. Cleaning up.`); + return null; + } + + return metadata; +} + +/** + * List all session IDs. + */ +export function listSessions(sessionDir: string): string[] { + ensureSessionDir(sessionDir); + const files = fs.readdirSync(sessionDir); + return files + .filter((f) => f.endsWith('.json')) + .map((f) => path.basename(f, '.json')); +} diff --git a/src/profile-query-cli/tests/basic.test.ts b/src/profile-query-cli/tests/basic.test.ts new file mode 100644 index 0000000000..262308bee7 --- /dev/null +++ b/src/profile-query-cli/tests/basic.test.ts @@ -0,0 +1,95 @@ +/** + * Basic CLI functionality tests. + * Migrated from bin/pq-test bash script. + */ + +import { readdir } from 'fs/promises'; +import { + createTestContext, + cleanupTestContext, + pq, + pqFail, + type PqTestContext, +} from './utils'; + +describe('pq basic functionality', () => { + let ctx: PqTestContext; + + beforeEach(async () => { + ctx = await createTestContext(); + }); + + afterEach(async () => { + await cleanupTestContext(ctx); + }); + + test('load creates a session', async () => { + const result = await pq(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Loading profile from'); + expect(result.stdout).toContain('Session started:'); + + // Extract session ID + expect(typeof result.stdout).toBe('string'); + const match = (result.stdout as string).match(/Session started: (\w+)/); + expect(match).toBeTruthy(); + const sessionId = match![1]; + + // Verify session files exist + const files = await readdir(ctx.sessionDir); + expect(files).toContain(`${sessionId}.sock`); + expect(files).toContain(`${sessionId}.json`); + expect(files).toContain('current.txt'); + }); + + test('profile info works after load', async () => { + await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const result = await pq(ctx, ['profile', 'info']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('This profile contains'); + }); + + test('stop cleans up session', async () => { + await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + await pq(ctx, ['stop']); + + // Verify socket is removed (the main cleanup requirement) + const files = await readdir(ctx.sessionDir); + expect(files.filter((f) => f.endsWith('.sock'))).toHaveLength(0); + }); + + test('load fails for missing file', async () => { + const result = await pqFail(ctx, ['load', '/nonexistent/file.json']); + + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('not found'); + }); + + test('profile info fails without active session', async () => { + const result = await pqFail(ctx, ['profile', 'info']); + + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('No active session'); + }); + + test('multiple profile info calls work (daemon stays running)', async () => { + await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + // First call + const result1 = await pq(ctx, ['profile', 'info']); + expect(result1.exitCode).toBe(0); + + // Second call - should still work (daemon running) + const result2 = await pq(ctx, ['profile', 'info']); + expect(result2.exitCode).toBe(0); + expect(result2.stdout).toEqual(result1.stdout); + }); +}); diff --git a/src/profile-query-cli/tests/daemon-startup.test.ts b/src/profile-query-cli/tests/daemon-startup.test.ts new file mode 100644 index 0000000000..8203874dd4 --- /dev/null +++ b/src/profile-query-cli/tests/daemon-startup.test.ts @@ -0,0 +1,116 @@ +/** + * Tests for two-phase daemon startup behavior. + * Verifies socket creation before profile loading and proper status reporting. + */ + +import { readFile, access } from 'fs/promises'; +import { join } from 'path'; +import { + createTestContext, + cleanupTestContext, + pq, + pqFail, + type PqTestContext, +} from './utils'; + +describe('daemon startup (two-phase)', () => { + let ctx: PqTestContext; + + beforeEach(async () => { + ctx = await createTestContext(); + }); + + afterEach(async () => { + await cleanupTestContext(ctx); + }); + + test('daemon creates socket and metadata before loading profile', async () => { + const startTime = Date.now(); + + const result = await pq(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + ]); + + const endTime = Date.now(); + const duration = endTime - startTime; + + expect(result.exitCode).toBe(0); + + // Should complete quickly (< 1 second for local file) + // The key improvement is that we don't wait for profile parsing + // before getting success feedback + expect(duration).toBeLessThan(2000); + + // Extract session ID + expect(typeof result.stdout).toBe('string'); + const match = (result.stdout as string).match(/Session started: (\w+)/); + const sessionId = match![1]; + + // Verify metadata file exists and contains correct info + const metadataPath = join(ctx.sessionDir, `${sessionId}.json`); + const metadata = JSON.parse(await readFile(metadataPath, 'utf-8')); + + expect(metadata.id).toBe(sessionId); + expect(metadata.socketPath).toContain(sessionId); + expect(metadata.pid).toBeNumber(); + expect(metadata.profilePath).toContain('processed-1.json'); + }); + + test('load returns non-zero exit code on profile load failure', async () => { + // Create an invalid JSON file + const invalidProfile = join(ctx.sessionDir, 'invalid.json'); + const { writeFile } = await import('fs/promises'); + await writeFile(invalidProfile, '{ invalid json content', 'utf-8'); + + const result = await pqFail(ctx, ['load', invalidProfile]); + + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toMatch(/Profile load failed|Failed to|parse|invalid/i); + }); + + test('daemon startup fails fast with short timeout', async () => { + // This test verifies Phase 1 timeout behavior + // We can't easily force a daemon startup failure, but we can + // verify the timeout is reasonable by checking it doesn't wait forever + + const result = await pqFail(ctx, ['load', '/nonexistent/file.json']); + + // Should fail quickly (Phase 1: 500ms for daemon, Phase 2: fails on validation) + expect(result.exitCode).not.toBe(0); + }); + + test('load blocks until profile is fully loaded', async () => { + // Start loading + await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + // If load returned, profile should be ready immediately + const result = await pq(ctx, ['profile', 'info']); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('This profile contains'); + }); + + test('validates session before returning (checks process + socket)', async () => { + const result = await pq(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + ]); + + expect(typeof result.stdout).toBe('string'); + const match = (result.stdout as string).match(/Session started: (\w+)/); + const sessionId = match![1]; + + // Verify both socket and metadata exist (validateSession checks both) + const socketPath = join(ctx.sessionDir, `${sessionId}.sock`); + const metadataPath = join(ctx.sessionDir, `${sessionId}.json`); + + await expect(access(socketPath)).resolves.toBeUndefined(); + await expect(access(metadataPath)).resolves.toBeUndefined(); + + // Process should be running (metadata contains PID) + const metadata = JSON.parse(await readFile(metadataPath, 'utf-8')); + expect(metadata.pid).toBeNumber(); + expect(metadata.pid).toBeGreaterThan(0); + }); +}); diff --git a/src/profile-query-cli/tests/sessions.test.ts b/src/profile-query-cli/tests/sessions.test.ts new file mode 100644 index 0000000000..c8aacb3bcd --- /dev/null +++ b/src/profile-query-cli/tests/sessions.test.ts @@ -0,0 +1,117 @@ +/** + * Multi-session tests. + * Migrated from bin/pq-test-multi bash script. + */ + +import { + createTestContext, + cleanupTestContext, + pq, + type PqTestContext, +} from './utils'; + +describe('pq multiple concurrent sessions', () => { + let ctx: PqTestContext; + + beforeEach(async () => { + ctx = await createTestContext(); + }); + + afterEach(async () => { + await cleanupTestContext(ctx); + }); + + test('can run multiple sessions with explicit IDs', async () => { + const session1 = 'test-session-1'; + const session2 = 'test-session-2'; + const session3 = 'test-session-3'; + + // Start three sessions + await pq(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + session1, + ]); + await pq(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + session2, + ]); + await pq(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-3.json', + '--session', + session3, + ]); + + // Query each session explicitly + const result1 = await pq(ctx, ['profile', 'info', '--session', session1]); + expect(result1.stdout).toContain('This profile contains'); + + const result2 = await pq(ctx, ['profile', 'info', '--session', session2]); + expect(result2.stdout).toContain('This profile contains'); + + // Query current session (should be session3) + const result3 = await pq(ctx, ['profile', 'info']); + expect(result3.stdout).toContain('This profile contains'); + + // Note: We don't assert that results differ, as different test profiles + // might coincidentally have identical summaries. + + // Stop all sessions + await pq(ctx, ['stop', '--session', session1]); + await pq(ctx, ['stop', '--session', session2]); + await pq(ctx, ['stop', '--session', session3]); + }); + + test('list-sessions shows running sessions', async () => { + // Start two sessions + await pq(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + 'session-a', + ]); + await pq(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + 'session-b', + ]); + + // List sessions + const result = await pq(ctx, ['list-sessions']); + + expect(result.stdout).toContain('Found 2 running sessions'); + expect(result.stdout).toContain('session-a'); + expect(result.stdout).toContain('session-b'); + + // Clean up + await pq(ctx, ['stop', '--all']); + }); + + test('stop --all stops all sessions', async () => { + // Start multiple sessions + await pq(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + 'session-1', + ]); + await pq(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + 'session-2', + ]); + + // Stop all + await pq(ctx, ['stop', '--all']); + + // Verify no sessions + const result = await pq(ctx, ['list-sessions']); + expect(result.stdout).toContain('Found 0 running sessions'); + }); +}); diff --git a/src/profile-query-cli/tests/setup.ts b/src/profile-query-cli/tests/setup.ts new file mode 100644 index 0000000000..a47b214d7c --- /dev/null +++ b/src/profile-query-cli/tests/setup.ts @@ -0,0 +1,7 @@ +/** + * Jest setup for CLI integration tests. + * These tests only need jest-extended, not the full browser test setup. + */ + +// Importing this makes jest-extended matchers available everywhere +import 'jest-extended/all'; diff --git a/src/profile-query-cli/tests/utils.ts b/src/profile-query-cli/tests/utils.ts new file mode 100644 index 0000000000..6f635c8a15 --- /dev/null +++ b/src/profile-query-cli/tests/utils.ts @@ -0,0 +1,161 @@ +/** + * Utilities for CLI integration tests. + */ + +import { spawn } from 'child_process'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const PQ_BIN = './src/profile-query-cli/dist/pq.js'; + +/** + * Simple command execution result. + */ +export interface CommandResult { + stdout: string; + stderr: string; + exitCode: number; +} + +/** + * Execute a command and return stdout, stderr, and exit code. + * Simple replacement for execa that works with Jest without ESM complications. + */ +function exec( + command: string, + args: string[], + options: { + env?: Record; + timeout?: number; + } = {} +): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { + env: { ...process.env, ...options.env }, + }); + + let stdout = ''; + let stderr = ''; + let timedOut = false; + let timeoutId: NodeJS.Timeout | undefined; + + if (options.timeout) { + timeoutId = setTimeout(() => { + timedOut = true; + proc.kill('SIGTERM'); + setTimeout(() => proc.kill('SIGKILL'), 1000); + }, options.timeout); + } + + proc.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('close', (code) => { + if (timeoutId) clearTimeout(timeoutId); + + if (timedOut) { + reject(new Error(`Command timed out after ${options.timeout}ms`)); + } else { + resolve({ + stdout, + stderr, + exitCode: code ?? 1, + }); + } + }); + + proc.on('error', (err) => { + if (timeoutId) clearTimeout(timeoutId); + reject(err); + }); + }); +} + +/** + * Context for a pq test session. + */ +export interface PqTestContext { + sessionDir: string; + env: Record; +} + +/** + * Create a test context with isolated session directory. + * Each test should call this in beforeEach() for maximum isolation. + */ +export async function createTestContext(): Promise { + const sessionDir = await mkdtemp(join(tmpdir(), 'pq-test-')); + return { + sessionDir, + env: { PQ_SESSION_DIR: sessionDir }, + }; +} + +/** + * Clean up test context. + * Each test should call this in afterEach() to remove temp directory. + */ +export async function cleanupTestContext(ctx: PqTestContext): Promise { + await rm(ctx.sessionDir, { recursive: true, force: true }); +} + +/** + * Run a pq command. + */ +export async function runPq( + ctx: PqTestContext, + args: string[], + options?: { + reject?: boolean; + timeout?: number; + } +): Promise { + const result = await exec(PQ_BIN, args, { + env: ctx.env, + timeout: options?.timeout ?? 30000, + }); + + // Throw if reject is true (default) and command failed + if ((options?.reject ?? true) && result.exitCode !== 0) { + const error = new Error(`Command failed with exit code ${result.exitCode}`); + Object.assign(error, result); + throw error; + } + + return result; +} + +/** + * Run a pq command and expect it to succeed. + */ +export async function pq( + ctx: PqTestContext, + args: string[] +): Promise { + return runPq(ctx, args); +} + +/** + * Run a pq command and expect it to fail. + */ +export async function pqFail( + ctx: PqTestContext, + args: string[] +): Promise { + try { + await runPq(ctx, args); + throw new Error('Expected command to fail but it succeeded'); + } catch (error) { + if (error instanceof Error && error.message.includes('Expected command')) { + throw error; + } + // Return the error as a result (which has stdout/stderr/exitCode attached) + return error as CommandResult; + } +} diff --git a/src/profile-query/README.md b/src/profile-query/README.md new file mode 100644 index 0000000000..3386e61bf5 --- /dev/null +++ b/src/profile-query/README.md @@ -0,0 +1,58 @@ +# Profile Query Library + +A library for programmatically querying the contents of a Firefox Profiler profile. + +## Usage + +**Note:** Most users should use the [profile-query-cli](../profile-query-cli/README.md) (`pq` command) instead of using this library directly. + +### Building + +```bash +yarn build-profile-query +``` + +### Programmatic Usage + +```javascript +// Node.js interactive session +const { ProfileQuerier } = (await import('./dist/profile-query.js')).default; + +// Load from file +const p1 = await ProfileQuerier.load('/path/to/profile.json.gz'); + +// Load from profiler.firefox.com URL +const p2 = await ProfileQuerier.load( + 'https://profiler.firefox.com/from-url/http%3A%2F%2Fexample.com%2Fprofile.json/' +); + +// Load from share URL +const p3 = await ProfileQuerier.load('https://share.firefox.dev/4oLEjCw'); + +// Query the profile +const profileInfo = await p1.profileInfo(); +const threadInfo = await p1.threadInfo(); +const samples = await p1.threadSamples(); +``` + +## Available Methods + +- `static async load(filePathOrUrl: string): Promise` - Load a profile from file or URL +- `async profileInfo(): Promise` - Get profile summary (processes, threads, CPU activity) +- `async threadInfo(threadHandle?: string): Promise` - Get detailed thread information +- `async threadSelect(threadHandle: string): Promise` - Select a thread for subsequent queries +- `async threadSamples(threadHandle?: string): Promise` - Get call tree and top functions for a thread +- `async pushViewRange(rangeName: string): Promise` - Push a zoom range (supports timestamps, marker handles, or time values) +- `async popViewRange(): Promise` - Pop the most recent zoom range +- `async getStatus(): Promise` - Get current session status (selected thread, zoom ranges) + +## Architecture + +The library is built on top of the Firefox Profiler's Redux store and selectors: + +- **ProfileQuerier**: Main class that wraps a Redux store and provides query methods +- **TimestampManager**: Manages timestamp naming for time range queries +- **ThreadMap**: Maps thread handles (e.g., "t-0", "t-1") to thread indices +- **Formatters**: Format query results as human-readable text + +All query results are returned as formatted strings, suitable for display in a terminal or log file. diff --git a/src/profile-query/cpu-activity.ts b/src/profile-query/cpu-activity.ts new file mode 100644 index 0000000000..dab814d5bc --- /dev/null +++ b/src/profile-query/cpu-activity.ts @@ -0,0 +1,208 @@ +import type { Slice, SliceTree } from 'firefox-profiler/utils/slice-tree'; +import type { TimestampManager } from './timestamps'; + +export interface CpuActivityEntry { + startTime: number; + startTimeName: string; + startTimeStr: string; // Formatted timestamp string (e.g., "6.991s") + endTime: number; + endTimeName: string; + endTimeStr: string; // Formatted timestamp string (e.g., "10.558s") + cpuMs: number; + depthLevel: number; +} + +function sliceToString( + slice: Slice, + time: number[], + tsManager: TimestampManager +): string { + const { avg, start, end } = slice; + const startTime = time[start]; + const endTime = time[end]; + const duration = endTime - startTime; + const startName = tsManager.nameForTimestamp(startTime); + const endName = tsManager.nameForTimestamp(endTime); + const startTimeStr = tsManager.timestampString(startTime); + const endTimeStr = tsManager.timestampString(endTime); + return `${Math.round(avg * 100)}% for ${duration.toFixed(1)}ms: [${startName} → ${endName}] (${startTimeStr} - ${endTimeStr})`; +} + +function appendSliceSubtree( + slices: Slice[], + startIndex: number, + parent: number | null, + childrenStartPerParent: number[], + interestingSliceIndexes: Set, + nestingDepth: number, + time: number[], + s: string[], + tsManager: TimestampManager +) { + for (let i = startIndex; i < slices.length; i++) { + if (!interestingSliceIndexes.has(i)) { + continue; + } + + const slice = slices[i]; + if (slice.parent !== parent) { + break; + } + + s.push( + ' '.repeat(nestingDepth) + '- ' + sliceToString(slice, time, tsManager) + ); + + const childrenStart = childrenStartPerParent[i]; + if (childrenStart !== null) { + appendSliceSubtree( + slices, + childrenStart, + i, + childrenStartPerParent, + interestingSliceIndexes, + nestingDepth + 1, + time, + s, + tsManager + ); + } + } +} + +export function printSliceTree( + { slices, time }: SliceTree, + tsManager: TimestampManager +): string[] { + if (slices.length === 0) { + return ['No significant activity.']; + } + + const childrenStartPerParent = new Array(slices.length); + const indexAndSumPerSlice = new Array(slices.length); + for (let i = 0; i < slices.length; i++) { + childrenStartPerParent[i] = null; + const { parent, sum } = slices[i]; + indexAndSumPerSlice.push({ i, sum }); + if (parent !== null && childrenStartPerParent[parent] === null) { + childrenStartPerParent[parent] = i; + } + } + indexAndSumPerSlice.sort((a, b) => b.sum - a.sum); + const interestingSliceIndexes = new Set( + indexAndSumPerSlice.slice(0, 20).map((x) => x.i) + ); + // console.log(interestingSliceIndexes); + + const s = new Array(); + appendSliceSubtree( + slices, + 0, + null, + childrenStartPerParent, + interestingSliceIndexes, + 0, + time, + s, + tsManager + ); + + return s; +} + +/** + * Collect CPU activity slices as structured data. + */ +export function collectSliceTree( + { slices, time }: SliceTree, + tsManager: TimestampManager +): CpuActivityEntry[] { + if (slices.length === 0) { + return []; + } + + const childrenStartPerParent = new Array(slices.length); + const indexAndSumPerSlice = new Array(slices.length); + for (let i = 0; i < slices.length; i++) { + childrenStartPerParent[i] = null; + const { parent, sum } = slices[i]; + indexAndSumPerSlice.push({ i, sum }); + if (parent !== null && childrenStartPerParent[parent] === null) { + childrenStartPerParent[parent] = i; + } + } + indexAndSumPerSlice.sort((a, b) => b.sum - a.sum); + const interestingSliceIndexes = new Set( + indexAndSumPerSlice.slice(0, 20).map((x) => x.i) + ); + + const result: CpuActivityEntry[] = []; + collectSliceSubtree( + slices, + 0, + null, + childrenStartPerParent, + interestingSliceIndexes, + 0, + time, + result, + tsManager + ); + + return result; +} + +function collectSliceSubtree( + slices: Slice[], + startIndex: number, + parent: number | null, + childrenStartPerParent: number[], + interestingSliceIndexes: Set, + nestingDepth: number, + time: number[], + result: CpuActivityEntry[], + tsManager: TimestampManager +) { + for (let i = startIndex; i < slices.length; i++) { + if (!interestingSliceIndexes.has(i)) { + continue; + } + + const slice = slices[i]; + if (slice.parent !== parent) { + break; + } + + const { start, end, avg } = slice; + const startTime = time[start]; + const endTime = time[end]; + const duration = endTime - startTime; + const cpuMs = duration * avg; + + result.push({ + startTime, + startTimeName: tsManager.nameForTimestamp(startTime), + startTimeStr: tsManager.timestampString(startTime), + endTime, + endTimeName: tsManager.nameForTimestamp(endTime), + endTimeStr: tsManager.timestampString(endTime), + cpuMs, + depthLevel: nestingDepth, + }); + + const childrenStart = childrenStartPerParent[i]; + if (childrenStart !== null) { + collectSliceSubtree( + slices, + childrenStart, + i, + childrenStartPerParent, + interestingSliceIndexes, + nestingDepth + 1, + time, + result, + tsManager + ); + } + } +} diff --git a/src/profile-query/formatters/call-tree.ts b/src/profile-query/formatters/call-tree.ts new file mode 100644 index 0000000000..06954b8a27 --- /dev/null +++ b/src/profile-query/formatters/call-tree.ts @@ -0,0 +1,340 @@ +/* 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/. */ + +import type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; +import type { + IndexIntoCallNodeTable, + ThreadIndex, + Lib, +} from 'firefox-profiler/types'; +import type { CallTreeNode, CallTreeScoringStrategy } from '../types'; +import type { FunctionMap } from '../function-map'; +import { formatFunctionNameWithLibrary } from '../function-list'; + +/** + * Compute inclusion score for a call tree node. + * The score determines priority for node budget selection. + * Property: score(child) ≤ score(parent) for any parent-child pair. + */ +function computeInclusionScore( + totalPercentage: number, + depth: number, + strategy: CallTreeScoringStrategy +): number { + switch (strategy) { + case 'exponential-0.95': + return totalPercentage * Math.pow(0.95, depth); + case 'exponential-0.9': + return totalPercentage * Math.pow(0.9, depth); + case 'exponential-0.8': + return totalPercentage * Math.pow(0.8, depth); + case 'harmonic-0.1': + return totalPercentage / (1 + 0.1 * depth); + case 'harmonic-0.5': + return totalPercentage / (1 + 0.5 * depth); + case 'harmonic-1.0': + return totalPercentage / (1 + depth); + case 'percentage-only': + return totalPercentage; + default: + // Default to exponential-0.9 + return totalPercentage * Math.pow(0.94, depth); + } +} + +/** + * Simple max-heap implementation for priority queue. + */ +class MaxHeap { + private items: Array<{ item: T; priority: number }> = []; + + push(item: T, priority: number): void { + this.items.push({ item, priority }); + this._bubbleUp(this.items.length - 1); + } + + popMax(): T | null { + if (this.items.length === 0) { + return null; + } + if (this.items.length === 1) { + return this.items.pop()!.item; + } + + const max = this.items[0].item; + this.items[0] = this.items.pop()!; + this._bubbleDown(0); + return max; + } + + isEmpty(): boolean { + return this.items.length === 0; + } + + size(): number { + return this.items.length; + } + + private _bubbleUp(index: number): void { + while (index > 0) { + const parentIndex = Math.floor((index - 1) / 2); + if (this.items[index].priority <= this.items[parentIndex].priority) { + break; + } + // Swap + [this.items[index], this.items[parentIndex]] = [ + this.items[parentIndex], + this.items[index], + ]; + index = parentIndex; + } + } + + private _bubbleDown(index: number): void { + while (true) { + const leftChild = 2 * index + 1; + const rightChild = 2 * index + 2; + let largest = index; + + if ( + leftChild < this.items.length && + this.items[leftChild].priority > this.items[largest].priority + ) { + largest = leftChild; + } + + if ( + rightChild < this.items.length && + this.items[rightChild].priority > this.items[largest].priority + ) { + largest = rightChild; + } + + if (largest === index) { + break; + } + + // Swap + [this.items[index], this.items[largest]] = [ + this.items[largest], + this.items[index], + ]; + index = largest; + } + } +} + +/** + * Internal node used during collection. + */ +type CollectionNode = { + callNodeIndex: IndexIntoCallNodeTable; + depth: number; +}; + +/** + * Options for call tree collection. + */ +export type CallTreeCollectionOptions = { + /** Maximum number of nodes to include. Default: 100 */ + maxNodes?: number; + /** Scoring strategy for node selection. Default: 'exponential-0.9' */ + scoringStrategy?: CallTreeScoringStrategy; + /** Maximum depth to traverse (safety limit). Default: 200 */ + maxDepth?: number; + /** Maximum children to expand per node. Default: 100 */ + maxChildrenPerNode?: number; +}; + +/** + * Collect call tree data using heap-based expansion. + * This works for both top-down and bottom-up (inverted) trees. + */ +export function collectCallTree( + tree: CallTree, + functionMap: FunctionMap, + threadIndexes: Set, + libs: Lib[], + options: CallTreeCollectionOptions = {} +): CallTreeNode { + const maxNodes = options.maxNodes ?? 100; + const scoringStrategy = options.scoringStrategy ?? 'exponential-0.9'; + const maxDepth = options.maxDepth ?? 200; + const maxChildrenPerNode = options.maxChildrenPerNode ?? 100; + + // Map from call node index to our collection node + const includedNodes = new Set(); + const expansionFrontier = new MaxHeap(); + + // Start with root nodes + // For inverted (bottom-up) trees, there can be many roots (all leaf functions). + // Reserve some budget for expanding children by limiting initial roots to ~70% of budget. + const roots = tree.getRoots(); + const maxInitialRoots = Math.min(roots.length, Math.ceil(maxNodes * 0.7)); + for (let i = 0; i < maxInitialRoots; i++) { + const rootIndex = roots[i]; + const nodeData = tree.getNodeData(rootIndex); + const totalPercentage = nodeData.totalRelative * 100; + const score = computeInclusionScore(totalPercentage, 0, scoringStrategy); + + const collectionNode: CollectionNode = { + callNodeIndex: rootIndex, + depth: 0, + }; + + expansionFrontier.push(collectionNode, score); + } + + // Expand nodes in score order until budget reached + while (includedNodes.size < maxNodes) { + const node = expansionFrontier.popMax(); + if (!node) { + break; + } + + // node is the next highest candidate; none of the other nodes in expansionFronteer, or + // any of their descendants, will have a higher score than node. Add it to the included + // set. + includedNodes.add(node.callNodeIndex); + + // Skip children if we've reached max depth + if (node.depth >= maxDepth || !tree.hasChildren(node.callNodeIndex)) { + continue; + } + + const childDepth = node.depth + 1; + + const children = tree.getChildren(node.callNodeIndex); + // Limit children per node to prevent budget explosion + const childrenToExpand = children.slice(0, maxChildrenPerNode); + + for (const childIndex of childrenToExpand) { + const childData = tree.getNodeData(childIndex); + const totalPercentage = childData.totalRelative * 100; + const childScore = computeInclusionScore( + totalPercentage, + childDepth, + scoringStrategy + ); + + const childNode: CollectionNode = { + callNodeIndex: childIndex, + depth: childDepth, + }; + + expansionFrontier.push(childNode, childScore); + } + } + + return buildTreeStructure( + tree, + includedNodes, + functionMap, + threadIndexes, + libs + ); +} + +/** + * Build tree structure from the set of included nodes. + */ +function buildTreeStructure( + tree: CallTree, + includedNodes: Set, + functionMap: FunctionMap, + threadIndexes: Set, + libs: Lib[] +): CallTreeNode { + // Get total sample count from the tree for percentage calculations + const totalSampleCount = tree.getTotal(); + + // Create virtual root + const rootNode: CallTreeNode = { + name: '', + nameWithLibrary: '', + totalSamples: totalSampleCount, + totalPercentage: 100, + selfSamples: 0, + selfPercentage: 0, + originalDepth: -1, + children: [], + }; + + const pendingNodes = [rootNode]; + + // Create tree nodes for all included nodes. + // Traverse the tree until we run out of pendingNodes. + while (true) { + const node = pendingNodes.pop(); + if (node === undefined) { + break; + } + + const childrenCallNodeIndexes = + node.callNodeIndex !== undefined + ? tree.getChildren(node.callNodeIndex) + : tree.getRoots(); + const elidedChildren = []; + const childrenDepth = node.originalDepth + 1; + for (const callNodeIndex of childrenCallNodeIndexes) { + if (!includedNodes.has(callNodeIndex)) { + elidedChildren.push(callNodeIndex); + continue; + } + const childNodeData = tree.getNodeData(callNodeIndex); + const funcIndex = tree._callNodeInfo.funcForNode(callNodeIndex); + const totalPercentage = childNodeData.totalRelative * 100; + + // Format function name with library prefix + const nameWithLibrary = formatFunctionNameWithLibrary( + funcIndex, + tree._thread, + libs + ); + + const childNode: CallTreeNode = { + callNodeIndex, + functionHandle: functionMap.handleForFunction(threadIndexes, funcIndex), + functionIndex: funcIndex, + name: childNodeData.funcName, + nameWithLibrary, + totalSamples: childNodeData.total, + totalPercentage, + selfSamples: childNodeData.self, + selfPercentage: childNodeData.selfRelative * 100, + originalDepth: childrenDepth, + children: [], + }; + + node.children.push(childNode); + pendingNodes.push(childNode); + } + + // Create elision marker if there are any elided or unexpanded children + if (elidedChildren.length > 0) { + let combinedSamples = 0; + let maxSamples = 0; + + // Stats for elided children that were expanded + for (const childIdx of elidedChildren) { + const childData = tree.getNodeData(childIdx); + combinedSamples += childData.total; + maxSamples = Math.max(maxSamples, childData.total); + } + + const combinedRelative = combinedSamples / totalSampleCount; + const maxRelative = maxSamples / totalSampleCount; + node.childrenTruncated = { + count: elidedChildren.length, + combinedSamples, + combinedPercentage: combinedRelative * 100, + maxSamples, + maxPercentage: maxRelative * 100, + depth: childrenDepth, + }; + } + } + + return rootNode; +} diff --git a/src/profile-query/formatters/marker-info.ts b/src/profile-query/formatters/marker-info.ts new file mode 100644 index 0000000000..d118f00e49 --- /dev/null +++ b/src/profile-query/formatters/marker-info.ts @@ -0,0 +1,1518 @@ +/* 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/. */ + +import { getSelectedThreadIndexes } from 'firefox-profiler/selectors/url-state'; +import { + getProfile, + getCategories, + getMarkerSchemaByName, + getStringTable, +} from 'firefox-profiler/selectors/profile'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + formatFromMarkerSchema, + getLabelGetter, +} from 'firefox-profiler/profile-logic/marker-schema'; +import { formatTimestamp } from 'firefox-profiler/utils/format-numbers'; +import { changeMarkersSearchString } from '../../actions/profile-view'; +import { + formatFunctionNameWithLibrary, + truncateFunctionName, +} from '../function-list'; +import type { Store } from '../../types/store'; +import type { ThreadMap } from '../thread-map'; +import type { MarkerMap } from '../marker-map'; +import type { + Marker, + MarkerIndex, + CategoryList, + Thread, + Lib, + IndexIntoStackTable, +} from 'firefox-profiler/types'; +import type { + MarkerStackResult, + MarkerInfoResult, + StackTraceData, + ThreadMarkersResult, + MarkerGroupData, + DurationStats, + RateStats, + MarkerFilterOptions, +} from '../types'; + +/** + * Aggregated statistics for a group of markers. + */ +interface MarkerTypeStats { + markerName: string; + count: number; + isInterval: boolean; + durationStats?: DurationStats; + rateStats?: RateStats; + topMarkers: Array<{ + handle: string; + label: string; + start: number; + duration?: number; + hasStack?: boolean; + }>; + subGroups?: MarkerGroup[]; // Sub-groups for multi-level grouping + subGroupKey?: string; // The key used for sub-grouping (e.g., "eventType" for auto-grouped fields) +} + +/** + * A group of markers with a common grouping key value. + */ +interface MarkerGroup { + groupName: string; + count: number; + isInterval: boolean; + durationStats?: DurationStats; + rateStats?: RateStats; + topMarkers: Array<{ + handle: string; + label: string; + start: number; + duration?: number; + hasStack?: boolean; + }>; + subGroups?: MarkerGroup[]; // Recursive sub-grouping +} + +/** + * A grouping key specifies how to group markers. + */ +type GroupingKey = + | 'type' // Group by marker type (data.type) + | 'name' // Group by marker name + | 'category' // Group by category name + | { field: string }; // Group by a specific field value + +/** + * Compute duration statistics for a list of markers. + * Only applies to interval markers (markers with an end time). + * Exported for testing. + */ +export function computeDurationStats( + markers: Marker[] +): DurationStats | undefined { + const durations = markers + .filter((m) => m.end !== null) + .map((m) => m.end! - m.start) + .sort((a, b) => a - b); + + if (durations.length === 0) { + return undefined; + } + + return { + min: durations[0], + max: durations[durations.length - 1], + avg: durations.reduce((a, b) => a + b, 0) / durations.length, + median: durations[Math.floor(durations.length / 2)], + p95: durations[Math.floor(durations.length * 0.95)], + p99: durations[Math.floor(durations.length * 0.99)], + }; +} + +/** + * Compute rate statistics for a list of markers (gaps between markers). + * Exported for testing. + */ +export function computeRateStats(markers: Marker[]): RateStats { + if (markers.length < 2) { + return { + markersPerSecond: 0, + minGap: 0, + avgGap: 0, + maxGap: 0, + }; + } + + const sorted = [...markers].sort((a, b) => a.start - b.start); + const gaps: number[] = []; + + for (let i = 1; i < sorted.length; i++) { + gaps.push(sorted[i].start - sorted[i - 1].start); + } + + const timeRange = sorted[sorted.length - 1].start - sorted[0].start; + // timeRange is in milliseconds, convert to seconds for rate + const markersPerSecond = + timeRange > 0 ? (markers.length / timeRange) * 1000 : 0; + + return { + markersPerSecond, + minGap: Math.min(...gaps), + avgGap: gaps.reduce((a, b) => a + b, 0) / gaps.length, + maxGap: Math.max(...gaps), + }; +} + +/** + * Format a duration in milliseconds to a human-readable string. + * Exported for testing. + */ +export function formatDuration(ms: number): string { + if (ms < 1) { + return `${(ms * 1000).toFixed(2)}μs`; + } else if (ms < 1000) { + return `${ms.toFixed(2)}ms`; + } + return `${(ms / 1000).toFixed(2)}s`; +} + +/** + * Apply all marker filters to a list of marker indexes. + * Returns the filtered list of marker indexes. + */ +function applyMarkerFilters( + markerIndexes: MarkerIndex[], + markers: Marker[], + categories: CategoryList, + filterOptions: MarkerFilterOptions +): MarkerIndex[] { + let filteredIndexes = markerIndexes; + + const { minDuration, maxDuration, category, hasStack, limit } = filterOptions; + + // Apply duration filtering if specified + if (minDuration !== undefined || maxDuration !== undefined) { + filteredIndexes = filteredIndexes.filter((markerIndex) => { + const marker = markers[markerIndex]; + + // Skip instant markers (they have no duration) + if (marker.end === null) { + return false; + } + + const duration = marker.end - marker.start; + + // Check min duration constraint + if (minDuration !== undefined && duration < minDuration) { + return false; + } + + // Check max duration constraint + if (maxDuration !== undefined && duration > maxDuration) { + return false; + } + + return true; + }); + } + + // Apply category filtering if specified + if (category !== undefined) { + const categoryLower = category.toLowerCase(); + filteredIndexes = filteredIndexes.filter((markerIndex) => { + const marker = markers[markerIndex]; + const categoryName = categories[marker.category]?.name ?? 'Unknown'; + return categoryName.toLowerCase().includes(categoryLower); + }); + } + + // Apply hasStack filtering if specified + if (hasStack) { + filteredIndexes = filteredIndexes.filter((markerIndex) => { + const marker = markers[markerIndex]; + return marker.data && 'cause' in marker.data && marker.data.cause; + }); + } + + // Apply limit if specified (after all filters) + if (limit !== undefined && filteredIndexes.length > limit) { + filteredIndexes = filteredIndexes.slice(0, limit); + } + + return filteredIndexes; +} + +/** + * Create a top markers array from a list of marker items. + * Returns up to 5 top markers, sorted by duration if applicable. + */ +function createTopMarkersArray( + items: Array<{ marker: Marker; index: MarkerIndex }>, + threadIndexes: Set, + markerMap: MarkerMap, + getMarkerLabel: (markerIndex: MarkerIndex) => string, + maxCount: number = 5 +): Array<{ + handle: string; + label: string; + start: number; + duration?: number; + hasStack?: boolean; +}> { + const hasEnd = items.some((item) => item.marker.end !== null); + + // Get top markers - sort by duration if interval markers, otherwise take first N + const sortedItems = hasEnd + ? [...items].sort( + (a, b) => + b.marker.end! - b.marker.start - (a.marker.end! - a.marker.start) + ) + : items.slice(0, maxCount); + + return sortedItems.slice(0, maxCount).map((item) => { + const handle = markerMap.handleForMarker(threadIndexes, item.index); + const label = getMarkerLabel(item.index); + const duration = + item.marker.end !== null + ? item.marker.end - item.marker.start + : undefined; + const hasStack = Boolean( + item.marker.data && 'cause' in item.marker.data && item.marker.data.cause + ); + return { + handle, + label: label || item.marker.name, + start: item.marker.start, + duration, + hasStack, + }; + }); +} + +/** + * Parse a groupBy string into an array of grouping keys. + * Examples: + * "type" => ['type'] + * "type,name" => ['type', 'name'] + * "type,field:eventType" => ['type', {field: 'eventType'}] + */ +function parseGroupingKeys(groupBy: string): GroupingKey[] { + return groupBy.split(',').map((key) => { + const trimmed = key.trim(); + if (trimmed.startsWith('field:')) { + return { field: trimmed.substring(6) }; + } + return trimmed as 'type' | 'name' | 'category'; + }); +} + +/** + * Get the grouping value for a marker based on a grouping key. + */ +function getGroupingValue( + marker: Marker, + key: GroupingKey, + categories: CategoryList +): string { + if (key === 'type') { + return marker.data?.type ?? marker.name; + } else if (key === 'name') { + return marker.name; + } else if (key === 'category') { + return categories[marker.category]?.name ?? 'Unknown'; + } + // Field-based grouping + const fieldValue = (marker.data as any)?.[key.field]; + if (fieldValue === undefined || fieldValue === null) { + return '(no value)'; + } + return String(fieldValue); +} + +/** + * Analyze field variance for a group of markers to determine if sub-grouping would be useful. + * Returns the best field for grouping based on a scoring heuristic, or null if none found. + * + * Scoring heuristic: + * - Prefers fields with a moderate number of unique values (3-20 ideal) + * - Avoids fields with too many unique values (likely IDs or timestamps) + * - Avoids fields with too few unique values (not enough variety) + * - Prefers fields that appear in most markers (>80%) + * - Excludes fields that look like IDs (end with "ID" or "Id") + * - Prefers fields with semantic names (type, event, phase, status, etc.) + */ +function analyzeFieldVariance( + markers: Marker[] +): { field: string; variance: number } | null { + if (markers.length === 0) { + return null; + } + + // Get the marker schema for the first marker to find available fields + const firstMarkerType = markers[0].data?.type; + if (!firstMarkerType) { + return null; + } + + // Analyze each field to find the one with best score + const fieldScores: Array<{ + field: string; + score: number; + uniqueCount: number; + }> = []; + + // Get all field keys from the first marker's data + const sampleData = markers[0].data; + if (!sampleData) { + return null; + } + + const fieldKeys = Object.keys(sampleData).filter((key) => { + // Exclude metadata fields + if (key === 'type' || key === 'cause') { + return false; + } + // Exclude fields that look like IDs (end with "ID" or "Id") + if (key.endsWith('ID') || key.endsWith('Id')) { + return false; + } + return true; + }); + + for (const fieldKey of fieldKeys) { + const uniqueValues = new Set(); + let validCount = 0; + + for (const marker of markers) { + const value = (marker.data as any)?.[fieldKey]; + if (value !== undefined && value !== null) { + uniqueValues.add(String(value)); + validCount++; + } + } + + const uniqueCount = uniqueValues.size; + + // Skip fields that don't appear frequently enough + if (validCount < markers.length * 0.8) { + continue; + } + + // Skip fields with too few unique values (< 3) + if (uniqueCount < 3) { + continue; + } + + // Calculate score based on how good this field is for grouping + // Prefer fields with 3-20 unique values (ideal range) + let score = 0; + if (uniqueCount >= 3 && uniqueCount <= 20) { + // Ideal range: score 100 + score = 100; + } else if (uniqueCount > 20 && uniqueCount <= 50) { + // Acceptable range: score decreases with more unique values + score = 100 - (uniqueCount - 20) * 2; + } else if (uniqueCount > 50) { + // Too many unique values (likely IDs): very low score + score = 10; + } + + // Boost score for fields that appear in all markers + if (validCount === markers.length) { + score += 10; + } + + // Boost score for semantically meaningful field names + const semanticFields = [ + 'eventType', + 'phase', + 'status', + 'operation', + 'category', + ]; + if (semanticFields.includes(fieldKey)) { + score += 20; + } + + fieldScores.push({ field: fieldKey, score, uniqueCount }); + } + + // Return the field with highest score + if (fieldScores.length === 0) { + return null; + } + + fieldScores.sort((a, b) => b.score - a.score); + return { field: fieldScores[0].field, variance: fieldScores[0].score / 100 }; +} + +/** + * Format marker groups hierarchically and append to the lines array. + */ +function formatMarkerGroups( + lines: string[], + groups: MarkerGroup[], + indentLevel: number, + maxGroups: number = 15 +): void { + const indent = ' '.repeat(indentLevel); + const topGroups = groups.slice(0, maxGroups); + + for (const group of topGroups) { + let line = `${indent}${group.groupName.padEnd(25)} ${group.count.toString().padStart(5)} markers`; + + if (group.durationStats) { + const { min, avg, max } = group.durationStats; + line += ` (interval: min=${formatDuration(min)}, avg=${formatDuration(avg)}, max=${formatDuration(max)})`; + } else if (group.isInterval) { + line += ' (interval)'; + } else { + line += ' (instant)'; + } + + lines.push(line); + + // Recursively format sub-groups + if (group.subGroups && group.subGroups.length > 0) { + formatMarkerGroups(lines, group.subGroups, indentLevel + 1, 10); + } + } + + if (groups.length > maxGroups) { + lines.push(`${indent}... (${groups.length - maxGroups} more groups)`); + } +} + +/** + * Group markers by a sequence of grouping keys (multi-level grouping). + * Returns a hierarchical structure of groups. + */ +function groupMarkers( + markerGroup: Array<{ marker: Marker; index: MarkerIndex }>, + groupingKeys: GroupingKey[], + categories: CategoryList, + threadIndexes: Set, + markerMap: MarkerMap, + getMarkerLabel: (markerIndex: MarkerIndex) => string, + depth: number = 0 +): MarkerGroup[] { + if (groupingKeys.length === 0 || markerGroup.length === 0) { + return []; + } + + const [currentKey, ...remainingKeys] = groupingKeys; + const groups = new Map< + string, + Array<{ marker: Marker; index: MarkerIndex }> + >(); + + // Group by current key + for (const item of markerGroup) { + const groupValue = getGroupingValue(item.marker, currentKey, categories); + if (!groups.has(groupValue)) { + groups.set(groupValue, []); + } + groups.get(groupValue)!.push(item); + } + + const result: MarkerGroup[] = []; + for (const [groupName, items] of groups.entries()) { + const markers = items.map((item) => item.marker); + const hasEnd = markers.some((m) => m.end !== null); + const durationStats = hasEnd ? computeDurationStats(markers) : undefined; + const rateStats = computeRateStats(markers); + + // Get top markers + const topMarkers = createTopMarkersArray( + items, + threadIndexes, + markerMap, + getMarkerLabel + ); + + // Recursively group by remaining keys (limit depth to 3) + const subGroups = + remainingKeys.length > 0 && depth < 2 + ? groupMarkers( + items, + remainingKeys, + categories, + threadIndexes, + markerMap, + getMarkerLabel, + depth + 1 + ) + : undefined; + + result.push({ + groupName, + count: markers.length, + isInterval: hasEnd, + durationStats, + rateStats, + topMarkers, + subGroups, + }); + } + + // Sort by count descending + result.sort((a, b) => b.count - a.count); + + return result; +} + +/** + * Aggregate markers by type and compute statistics. + * Optionally applies auto-grouping or custom grouping. + */ +function aggregateMarkersByType( + markers: Marker[], + markerIndexes: MarkerIndex[], + threadIndexes: Set, + markerMap: MarkerMap, + getMarkerLabel: (markerIndex: MarkerIndex) => string, + categories: CategoryList, + autoGroup: boolean = false +): MarkerTypeStats[] { + // Convert Set to number if needed + const groups = new Map< + string, + Array<{ marker: Marker; index: MarkerIndex }> + >(); + + for (const markerIndex of markerIndexes) { + const marker = markers[markerIndex]; + const markerName = marker.name; + + if (!groups.has(markerName)) { + groups.set(markerName, []); + } + groups.get(markerName)!.push({ marker, index: markerIndex }); + } + + const stats: MarkerTypeStats[] = []; + + for (const [markerName, markerGroup] of groups.entries()) { + const markerList = markerGroup.map((g) => g.marker); + const hasEnd = markerList.some((m) => m.end !== null); + const durationStats = hasEnd ? computeDurationStats(markerList) : undefined; + const rateStats = computeRateStats(markerList); + + // Get top 5 markers by duration (or just first 5 for instant markers) + const topMarkers = createTopMarkersArray( + markerGroup, + threadIndexes, + markerMap, + getMarkerLabel + ); + + // Apply auto-grouping if enabled + let subGroups: MarkerGroup[] | undefined; + let subGroupKey: string | undefined; + if (autoGroup && markerList.length > 5) { + const fieldInfo = analyzeFieldVariance(markerList); + if (fieldInfo) { + // Sub-group by the field with highest variance + subGroups = groupMarkers( + markerGroup, + [{ field: fieldInfo.field }], + categories, + threadIndexes, + markerMap, + getMarkerLabel, + 1 + ); + subGroupKey = fieldInfo.field; + } + } + + stats.push({ + markerName: markerName, + count: markerList.length, + isInterval: hasEnd, + durationStats, + rateStats, + topMarkers, + subGroups, + subGroupKey, + }); + } + + // Sort by count descending + stats.sort((a, b) => b.count - a.count); + + return stats; +} + +/** + * Aggregate markers by category. + */ +function aggregateMarkersByCategory( + markers: Marker[], + markerIndexes: MarkerIndex[], + categories: CategoryList +): Array<{ categoryName: string; count: number; percentage: number }> { + const groups = new Map(); + + for (const markerIndex of markerIndexes) { + const marker = markers[markerIndex]; + const categoryName = categories[marker.category]?.name ?? 'Unknown'; + + groups.set(categoryName, (groups.get(categoryName) ?? 0) + 1); + } + + const total = markerIndexes.length; + const stats = Array.from(groups.entries()) + .map(([categoryName, count]) => ({ + categoryName, + count, + percentage: (count / total) * 100, + })) + .sort((a, b) => b.count - a.count); + + return stats; +} + +/** + * Format the marker listing for a thread. + */ +export function formatThreadMarkers( + store: Store, + threadMap: ThreadMap, + markerMap: MarkerMap, + threadHandle?: string, + filterOptions: MarkerFilterOptions = {} +): string { + // Apply marker search filter if provided + const searchString = filterOptions.searchString || ''; + if (searchString) { + store.dispatch(changeMarkersSearchString(searchString)); + } + + try { + // Get state after potentially dispatching the search action + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const fullMarkerList = threadSelectors.getFullMarkerList(state); + const categories = getCategories(state); + const markerSchemaByName = getMarkerSchemaByName(state); + const stringTable = getStringTable(state); + + // Get marker indexes - use search-filtered if search is active, otherwise all markers + const originalCount = + threadSelectors.getFullMarkerListIndexes(state).length; + let filteredIndexes = searchString + ? threadSelectors.getSearchFilteredMarkerIndexes(state) + : threadSelectors.getFullMarkerListIndexes(state); + + // Apply all marker filters + filteredIndexes = applyMarkerFilters( + filteredIndexes, + fullMarkerList, + categories, + filterOptions + ); + + // Get label getter for markers + const getMarkerLabel = getLabelGetter( + (markerIndex: MarkerIndex) => fullMarkerList[markerIndex], + getProfile(state).meta.markerSchema, + markerSchemaByName, + categories, + stringTable, + 'tableLabel' + ); + + const lines: string[] = []; + + // Generate thread handle for display + const displayThreadHandle = + threadHandle ?? threadMap.handleForThreadIndexes(threadIndexes); + + // Check if filters are active + const { minDuration, maxDuration, category, hasStack, limit } = + filterOptions; + const hasFilters = + !!searchString || + minDuration !== undefined || + maxDuration !== undefined || + category !== undefined || + hasStack || + limit !== undefined; + const filterSuffix = + hasFilters && filteredIndexes.length !== originalCount + ? ` (filtered from ${originalCount})` + : ''; + + lines.push( + `Markers in thread ${displayThreadHandle} (${friendlyThreadName}) — ${filteredIndexes.length} markers${filterSuffix}\n` + ); + + if (filteredIndexes.length === 0) { + if (hasFilters) { + lines.push('No markers match the specified filters.'); + } else { + lines.push('No markers in this thread.'); + } + return lines.join('\n'); + } + + const { groupBy, autoGroup } = filterOptions; + + // Handle custom grouping if groupBy is specified + if (groupBy) { + const groupingKeys = parseGroupingKeys(groupBy); + const markerGroups: Array<{ marker: Marker; index: MarkerIndex }> = []; + for (const markerIndex of filteredIndexes) { + markerGroups.push({ + marker: fullMarkerList[markerIndex], + index: markerIndex, + }); + } + + const groups = groupMarkers( + markerGroups, + groupingKeys, + categories, + threadIndexes, + markerMap, + getMarkerLabel, + 0 + ); + + // Format and display hierarchical groups + formatMarkerGroups(lines, groups, 0); + } else { + // Default aggregation by type (with optional auto-grouping) + const typeStats = aggregateMarkersByType( + fullMarkerList, + filteredIndexes, + threadIndexes, + markerMap, + getMarkerLabel, + categories, + autoGroup || false + ); + + // Show top 15 marker names + lines.push('By Name (top 15):'); + const topTypes = typeStats.slice(0, 15); + for (const stats of topTypes) { + let line = ` ${stats.markerName.padEnd(25)} ${stats.count.toString().padStart(5)} markers`; + + if (stats.durationStats) { + const { min, avg, max } = stats.durationStats; + line += ` (interval: min=${formatDuration(min)}, avg=${formatDuration(avg)}, max=${formatDuration(max)})`; + } else { + line += ' (instant)'; + } + + lines.push(line); + + // Show top markers with handles (for easy inspection) + if (!stats.subGroups && stats.topMarkers.length > 0) { + const handleList = stats.topMarkers + .slice(0, 3) + .map((m) => { + const handleOnly = m.handle; + if (m.duration !== undefined) { + return `${handleOnly} (${formatDuration(m.duration)})`; + } + return handleOnly; + }) + .join(', '); + lines.push(` Examples: ${handleList}`); + } + + // Show sub-groups if present (from auto-grouping) + if (stats.subGroups && stats.subGroups.length > 0) { + if (stats.subGroupKey) { + lines.push(` Grouped by ${stats.subGroupKey}:`); + } + formatMarkerGroups(lines, stats.subGroups, 2); + } + } + + if (typeStats.length > 15) { + lines.push(` ... (${typeStats.length - 15} more types)`); + } + + lines.push(''); + + // Aggregate by category (using filtered indexes) + const categoryStats = aggregateMarkersByCategory( + fullMarkerList, + filteredIndexes, + categories + ); + + lines.push('By Category:'); + for (const stats of categoryStats) { + lines.push( + ` ${stats.categoryName.padEnd(25)} ${stats.count.toString().padStart(5)} markers (${stats.percentage.toFixed(1)}%)` + ); + } + + lines.push(''); + + // Frequency analysis for top types + lines.push('Frequency Analysis:'); + const topRateTypes = typeStats + .filter((s) => s.rateStats && s.rateStats.markersPerSecond > 0) + .slice(0, 5); + + for (const stats of topRateTypes) { + if (!stats.rateStats) { + continue; + } + const { markersPerSecond, minGap, avgGap, maxGap } = stats.rateStats; + lines.push( + ` ${stats.markerName}: ${markersPerSecond.toFixed(1)} markers/sec (interval: min=${formatDuration(minGap)}, avg=${formatDuration(avgGap)}, max=${formatDuration(maxGap)})` + ); + } + + lines.push(''); + } + + lines.push( + 'Use --search , --category , --min-duration , --max-duration , --has-stack, --limit , --group-by , or --auto-group to filter/group markers, or m- handles to inspect individual markers.' + ); + + return lines.join('\n'); + } finally { + // Always clear the search string to avoid affecting other queries + if (searchString) { + store.dispatch(changeMarkersSearchString('')); + } + } +} + +/** + * Collect thread markers data in structured format for JSON output. + */ +export function collectThreadMarkers( + store: Store, + threadMap: ThreadMap, + markerMap: MarkerMap, + threadHandle?: string, + filterOptions: MarkerFilterOptions = {} +): ThreadMarkersResult { + // Apply marker search filter if provided + const searchString = filterOptions.searchString || ''; + if (searchString) { + store.dispatch(changeMarkersSearchString(searchString)); + } + + try { + // Get state after potentially dispatching the search action + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const fullMarkerList = threadSelectors.getFullMarkerList(state); + const categories = getCategories(state); + const markerSchemaByName = getMarkerSchemaByName(state); + const stringTable = getStringTable(state); + + // Get marker indexes - use search-filtered if search is active, otherwise all markers + const originalCount = + threadSelectors.getFullMarkerListIndexes(state).length; + let filteredIndexes = searchString + ? threadSelectors.getSearchFilteredMarkerIndexes(state) + : threadSelectors.getFullMarkerListIndexes(state); + + // Apply all marker filters + filteredIndexes = applyMarkerFilters( + filteredIndexes, + fullMarkerList, + categories, + filterOptions + ); + + // Get label getter for markers + const getMarkerLabel = getLabelGetter( + (markerIndex: MarkerIndex) => fullMarkerList[markerIndex], + getProfile(state).meta.markerSchema, + markerSchemaByName, + categories, + stringTable, + 'tableLabel' + ); + + // Generate thread handle for display + const displayThreadHandle = + threadHandle ?? threadMap.handleForThreadIndexes(threadIndexes); + + const { groupBy, autoGroup } = filterOptions; + + // Handle custom grouping if groupBy is specified + let customGroups: MarkerGroupData[] | undefined; + if (groupBy) { + const groupingKeys = parseGroupingKeys(groupBy); + const markerGroups: Array<{ marker: Marker; index: MarkerIndex }> = []; + for (const markerIndex of filteredIndexes) { + markerGroups.push({ + marker: fullMarkerList[markerIndex], + index: markerIndex, + }); + } + + const groups = groupMarkers( + markerGroups, + groupingKeys, + categories, + threadIndexes, + markerMap, + getMarkerLabel, + 0 + ); + + // Add markerIndex to topMarkers in groups + customGroups = addMarkerIndexToGroups(groups); + } + + // Aggregate by type (with optional auto-grouping) + const typeStats = aggregateMarkersByType( + fullMarkerList, + filteredIndexes, + threadIndexes, + markerMap, + getMarkerLabel, + categories, + autoGroup || false + ); + + // Convert typeStats to include markerIndex + const byType = typeStats.map((stats) => ({ + markerName: stats.markerName, + count: stats.count, + isInterval: stats.isInterval, + durationStats: stats.durationStats, + rateStats: stats.rateStats, + topMarkers: stats.topMarkers.map((m) => ({ + handle: m.handle, + label: m.label, + start: m.start, + duration: m.duration, + hasStack: m.hasStack, + })), + subGroups: stats.subGroups + ? addMarkerIndexToGroups(stats.subGroups) + : undefined, + subGroupKey: stats.subGroupKey, + })); + + // Aggregate by category (using filtered indexes) + const categoryStats = aggregateMarkersByCategory( + fullMarkerList, + filteredIndexes, + categories + ); + + const byCategory = categoryStats.map((stats) => ({ + categoryName: stats.categoryName, + categoryIndex: categories.findIndex( + (cat) => cat?.name === stats.categoryName + ), + count: stats.count, + percentage: stats.percentage, + })); + + // Build filters object (only include if filters were applied) + const { minDuration, maxDuration, category, hasStack, limit } = + filterOptions; + const filters = + searchString || + minDuration !== undefined || + maxDuration !== undefined || + category !== undefined || + hasStack || + limit !== undefined + ? { + searchString: searchString || undefined, + minDuration, + maxDuration, + category, + hasStack, + limit, + } + : undefined; + + return { + type: 'thread-markers', + threadHandle: displayThreadHandle, + friendlyThreadName, + totalMarkerCount: originalCount, + filteredMarkerCount: filteredIndexes.length, + filters, + byType, + byCategory, + customGroups, + }; + } finally { + // Always clear the search string to avoid affecting other queries + if (searchString) { + store.dispatch(changeMarkersSearchString('')); + } + } +} + +/** + * Helper to add markerIndex to topMarkers in MarkerGroup arrays. + */ +function addMarkerIndexToGroups(groups: MarkerGroup[]): MarkerGroupData[] { + return groups.map((group) => ({ + groupName: group.groupName, + count: group.count, + isInterval: group.isInterval, + durationStats: group.durationStats, + rateStats: group.rateStats, + topMarkers: group.topMarkers.map((m) => ({ + handle: m.handle, + label: m.label, + start: m.start, + duration: m.duration, + hasStack: m.hasStack, + })), + subGroups: group.subGroups + ? addMarkerIndexToGroups(group.subGroups) + : undefined, + })); +} + +/** + * Format a marker's cause stack trace. + * Returns an array of formatted stack frame strings. + */ +function formatMarkerStack( + stackIndex: IndexIntoStackTable | null, + thread: Thread, + libs: Lib[], + maxFrames: number = 20 +): string[] { + if (stackIndex === null) { + return ['(no stack trace)']; + } + + const { stackTable, frameTable } = thread; + const frames: string[] = []; + + // Walk up the stack table to collect all frames + let currentStackIndex: IndexIntoStackTable | null = stackIndex; + while (currentStackIndex !== null) { + const frameIndex = stackTable.frame[currentStackIndex]; + const funcIndex = frameTable.func[frameIndex]; + const funcName = formatFunctionNameWithLibrary(funcIndex, thread, libs); + frames.push(funcName); + currentStackIndex = stackTable.prefix[currentStackIndex]; + } + + const lines: string[] = []; + const totalFrames = frames.length; + + if (totalFrames === 0) { + return ['(empty stack)']; + } + + // Show up to maxFrames, with ellipsis if there are more + const framesToShow = Math.min(totalFrames, maxFrames); + for (let i = 0; i < framesToShow; i++) { + const displayName = truncateFunctionName(frames[i], 100); + lines.push(` [${i + 1}] ${displayName}`); + } + + if (totalFrames > maxFrames) { + lines.push(` ... (${totalFrames - maxFrames} more frames)`); + } + + return lines; +} + +/** + * Collect stack trace data in structured format. + */ +function collectStackTrace( + stackIndex: IndexIntoStackTable | null, + thread: Thread, + libs: Lib[], + capturedAt?: number +): StackTraceData | null { + if (stackIndex === null) { + return null; + } + + const { stackTable, frameTable, funcTable, stringTable, resourceTable } = + thread; + const frames: StackTraceData['frames'] = []; + + // Walk up the stack table to collect all frames + let currentStackIndex: IndexIntoStackTable | null = stackIndex; + while (currentStackIndex !== null) { + const frameIndex = stackTable.frame[currentStackIndex]; + const funcIndex = frameTable.func[frameIndex]; + + // Get function name + const funcName = stringTable.getString(funcTable.name[funcIndex]); + + // Get library name if available + const resourceIndex = funcTable.resource[funcIndex]; + let library: string | undefined; + let nameWithLibrary = funcName; + + if (resourceIndex !== -1) { + const libIndex = resourceTable.lib[resourceIndex]; + if (libIndex !== null && libs) { + const lib = libs[libIndex]; + library = lib.name; + nameWithLibrary = `${library}!${funcName}`; + } else { + const resourceName = stringTable.getString( + resourceTable.name[resourceIndex] + ); + if (resourceName && resourceName !== funcName) { + nameWithLibrary = `${resourceName}!${funcName}`; + } + } + } + + frames.push({ + name: funcName, + nameWithLibrary, + library, + }); + + currentStackIndex = stackTable.prefix[currentStackIndex]; + } + + return { + frames, + truncated: false, + capturedAt, + }; +} + +/** + * Collect marker stack trace data in structured format. + */ +export function collectMarkerStack( + store: Store, + markerMap: MarkerMap, + threadMap: ThreadMap, + markerHandle: string +): MarkerStackResult { + const state = store.getState(); + const { threadIndexes, markerIndex } = + markerMap.markerForHandle(markerHandle); + + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const fullMarkerList = threadSelectors.getFullMarkerList(state); + const marker = fullMarkerList[markerIndex]; + + if (!marker) { + throw new Error(`Marker ${markerHandle} not found`); + } + + const threadHandleDisplay = threadMap.handleForThreadIndexes(threadIndexes); + const profile = getProfile(state); + const thread = threadSelectors.getFilteredThread(state); + const libs = profile.libs; + + // Check if marker has a stack trace + let stack: StackTraceData | null = null; + if (marker.data && 'cause' in marker.data && marker.data.cause) { + const cause = marker.data.cause; + stack = collectStackTrace(cause.stack, thread, libs, cause.time); + } + + return { + type: 'marker-stack', + markerHandle, + markerIndex, + threadHandle: threadHandleDisplay, + friendlyThreadName, + markerName: marker.name, + stack, + }; +} + +/** + * Collect detailed marker information in structured format. + */ +export function collectMarkerInfo( + store: Store, + markerMap: MarkerMap, + threadMap: ThreadMap, + markerHandle: string +): MarkerInfoResult { + const state = store.getState(); + const { threadIndexes, markerIndex } = + markerMap.markerForHandle(markerHandle); + + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const fullMarkerList = threadSelectors.getFullMarkerList(state); + const marker = fullMarkerList[markerIndex]; + + if (!marker) { + throw new Error(`Marker ${markerHandle} not found`); + } + + const categories = getCategories(state); + const markerSchemaByName = getMarkerSchemaByName(state); + const stringTable = getStringTable(state); + const threadHandleDisplay = threadMap.handleForThreadIndexes(threadIndexes); + + // Get tooltip label + const getTooltipLabel = getLabelGetter( + (mi: MarkerIndex) => fullMarkerList[mi], + getProfile(state).meta.markerSchema, + markerSchemaByName, + categories, + stringTable, + 'tooltipLabel' + ); + const tooltipLabel = getTooltipLabel(markerIndex); + + // Collect marker fields + let fields: MarkerInfoResult['fields']; + let schemaInfo: MarkerInfoResult['schema']; + + if (marker.data) { + const schema = markerSchemaByName[marker.data.type]; + if (schema && schema.fields.length > 0) { + fields = []; + for (const field of schema.fields) { + if (field.hidden) { + continue; + } + + const value = (marker.data as any)[field.key]; + if (value !== undefined && value !== null) { + const formattedValue = formatFromMarkerSchema( + marker.data.type, + field.format, + value, + stringTable + ); + fields.push({ + key: field.key, + label: field.label || field.key, + value, + formattedValue, + }); + } + } + } + + // Include schema description if available + if (schema?.description) { + schemaInfo = { description: schema.description }; + } + } + + // Collect stack trace if available (truncated to 20 frames) + let stack: StackTraceData | undefined; + if (marker.data && 'cause' in marker.data && marker.data.cause) { + const cause = marker.data.cause; + const profile = getProfile(state); + const thread = threadSelectors.getFilteredThread(state); + const libs = profile.libs; + + const fullStack = collectStackTrace(cause.stack, thread, libs, cause.time); + if (fullStack && fullStack.frames.length > 0) { + // Truncate to 20 frames + const truncated = fullStack.frames.length > 20; + stack = { + frames: fullStack.frames.slice(0, 20), + truncated, + capturedAt: fullStack.capturedAt, + }; + } + } + + return { + type: 'marker-info', + markerHandle, + markerIndex, + threadHandle: threadHandleDisplay, + friendlyThreadName, + name: marker.name, + tooltipLabel: tooltipLabel || undefined, + markerType: marker.data?.type, + category: { + index: marker.category, + name: categories[marker.category]?.name ?? 'Unknown', + }, + start: marker.start, + end: marker.end, + duration: marker.end !== null ? marker.end - marker.start : undefined, + fields, + schema: schemaInfo, + stack, + }; +} + +/** + * Format a marker's full stack trace. + * Shows all frames without limit. + */ +export function formatMarkerStackFull( + store: Store, + markerMap: MarkerMap, + threadMap: ThreadMap, + markerHandle: string +): string { + const state = store.getState(); + const { threadIndexes, markerIndex } = + markerMap.markerForHandle(markerHandle); + + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const fullMarkerList = threadSelectors.getFullMarkerList(state); + const marker = fullMarkerList[markerIndex]; + + if (!marker) { + throw new Error(`Marker ${markerHandle} not found`); + } + + const lines: string[] = []; + const threadHandleDisplay = threadMap.handleForThreadIndexes(threadIndexes); + + lines.push(`Stack trace for marker ${markerHandle}: ${marker.name}\n`); + lines.push(`Thread: ${threadHandleDisplay} (${friendlyThreadName})`); + + // Check if marker has a stack trace + if (!marker.data || !('cause' in marker.data) || !marker.data.cause) { + lines.push('\n(This marker has no stack trace)'); + return lines.join('\n'); + } + + const cause = marker.data.cause; + const profile = getProfile(state); + const thread = threadSelectors.getFilteredThread(state); + const libs = profile.libs; + + if (cause.time !== undefined) { + const causeTimeStr = formatTimestamp(cause.time); + lines.push(`Captured at: ${causeTimeStr}\n`); + } + + const stackLines = formatMarkerStack(cause.stack, thread, libs, Infinity); + lines.push(...stackLines); + + return lines.join('\n'); +} + +/** + * Format detailed information about a specific marker. + */ +export function formatMarkerInfo( + store: Store, + markerMap: MarkerMap, + threadMap: ThreadMap, + markerHandle: string +): string { + const state = store.getState(); + const { threadIndexes, markerIndex } = + markerMap.markerForHandle(markerHandle); + + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const fullMarkerList = threadSelectors.getFullMarkerList(state); + const marker = fullMarkerList[markerIndex]; + + if (!marker) { + throw new Error(`Marker ${markerHandle} not found`); + } + + const categories = getCategories(state); + const markerSchemaByName = getMarkerSchemaByName(state); + const stringTable = getStringTable(state); + + const lines: string[] = []; + const threadHandleDisplay = threadMap.handleForThreadIndexes(threadIndexes); + + // Get tooltip label + const getTooltipLabel = getLabelGetter( + (mi: MarkerIndex) => fullMarkerList[mi], + getProfile(state).meta.markerSchema, + markerSchemaByName, + categories, + stringTable, + 'tooltipLabel' + ); + const tooltipLabel = getTooltipLabel(markerIndex); + + lines.push( + `Marker ${markerHandle}: ${marker.name}${tooltipLabel ? ` - ${tooltipLabel}` : ''}\n` + ); + + // Basic info + lines.push(`Type: ${marker.data?.type ?? 'None'}`); + lines.push(`Category: ${categories[marker.category]?.name ?? 'Unknown'}`); + + const startStr = formatTimestamp(marker.start); + if (marker.end !== null) { + const endStr = formatTimestamp(marker.end); + const duration = marker.end - marker.start; + lines.push(`Time: ${startStr} - ${endStr} (${formatDuration(duration)})`); + } else { + lines.push(`Time: ${startStr} (instant)`); + } + + lines.push(`Thread: ${threadHandleDisplay} (${friendlyThreadName})`); + + // Marker data fields + if (marker.data) { + const schema = markerSchemaByName[marker.data.type]; + if (schema && schema.fields.length > 0) { + lines.push('\nFields:'); + for (const field of schema.fields) { + if (field.hidden) { + continue; + } + + const value = (marker.data as any)[field.key]; + if (value !== undefined && value !== null) { + const formattedValue = formatFromMarkerSchema( + marker.data.type, + field.format, + value, + stringTable + ); + lines.push(` ${field.label || field.key}: ${formattedValue}`); + } + } + } + + // Show description if available + if (schema?.description) { + lines.push(`\nDescription:`); + lines.push(` ${schema.description}`); + } + } + + // Show stack trace if available + if (marker.data && 'cause' in marker.data && marker.data.cause) { + const cause = marker.data.cause; + const profile = getProfile(state); + const thread = threadSelectors.getFilteredThread(state); + const libs = profile.libs; + + lines.push('\nStack trace:'); + if (cause.time !== undefined) { + const causeTimeStr = formatTimestamp(cause.time); + lines.push(` Captured at: ${causeTimeStr}`); + } + + const stackLines = formatMarkerStack(cause.stack, thread, libs, 20); + lines.push(...stackLines); + + if (stackLines.length > 21) { + lines.push( + `\nUse 'pq marker stack ${markerHandle}' for the full stack trace.` + ); + } + } + + return lines.join('\n'); +} diff --git a/src/profile-query/formatters/profile-info.ts b/src/profile-query/formatters/profile-info.ts new file mode 100644 index 0000000000..3dc55cc475 --- /dev/null +++ b/src/profile-query/formatters/profile-info.ts @@ -0,0 +1,114 @@ +/* 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/. */ + +import { + getProfile, + getThreadCPUTimeMs, + getRangeFilteredCombinedThreadActivitySlices, +} from 'firefox-profiler/selectors/profile'; +import { getProfileNameWithDefault } from 'firefox-profiler/selectors/url-state'; +import { buildProcessThreadList } from '../process-thread-list'; +import { collectSliceTree } from '../cpu-activity'; +import type { Store } from '../../types/store'; +import type { ThreadInfo } from '../process-thread-list'; +import type { TimestampManager } from '../timestamps'; +import type { ThreadMap } from '../thread-map'; +import type { ProfileInfoResult } from '../types'; + +/** + * Collect profile information in structured format. + */ +export function collectProfileInfo( + store: Store, + timestampManager: TimestampManager, + threadMap: ThreadMap, + processIndexMap: Map +): ProfileInfoResult { + const state = store.getState(); + const profile = getProfile(state); + const profileName = getProfileNameWithDefault(state); + const processCount = new Set(profile.threads.map((t) => t.pid)).size; + const threadCPUTimeMs = getThreadCPUTimeMs(state); + + // Build thread info array + const threads: ThreadInfo[] = profile.threads.map((thread, index) => ({ + threadIndex: index, + name: thread.name, + cpuMs: threadCPUTimeMs ? threadCPUTimeMs[index] : 0, + pid: thread.pid, + })); + + // Build the process/thread list + const result = buildProcessThreadList(threads, processIndexMap); + + // Apply process names and timing from the profile + const processesData: ProfileInfoResult['processes'] = result.processes.map( + (processItem) => { + // Find a thread from this process to get the process name and timing + const threadFromProcess = profile.threads.find( + (t) => t.pid === processItem.pid + ); + + let processName = 'unknown'; + let startTime: number | undefined; + let startTimeName: string | undefined; + let endTime: number | null | undefined; + let endTimeName: string | null | undefined; + + if (threadFromProcess) { + processName = + threadFromProcess.processName || + threadFromProcess.processType || + 'unknown'; + startTime = threadFromProcess.processStartupTime; + if (startTime !== undefined) { + startTimeName = timestampManager.nameForTimestamp(startTime); + endTime = threadFromProcess.processShutdownTime; + if (endTime !== null && endTime !== undefined) { + endTimeName = timestampManager.nameForTimestamp(endTime); + } else { + endTimeName = null; + } + } + } + + return { + processIndex: processItem.processIndex, + pid: processItem.pid, + name: processName, + cpuMs: processItem.cpuMs, + startTime, + startTimeName, + endTime, + endTimeName, + threads: processItem.threads.map((thread) => ({ + threadIndex: thread.threadIndex, + threadHandle: threadMap.handleForThreadIndex(thread.threadIndex), + name: thread.name, + cpuMs: thread.cpuMs, + })), + remainingThreads: processItem.remainingThreads, + }; + } + ); + + // Collect CPU activity (respecting zoom) + const combinedCpuActivity = + getRangeFilteredCombinedThreadActivitySlices(state); + const cpuActivity = + combinedCpuActivity !== null + ? collectSliceTree(combinedCpuActivity, timestampManager) + : null; + + return { + type: 'profile-info', + name: profileName || 'Unknown Profile', + platform: profile.meta.oscpu || 'Unknown', + threadCount: profile.threads.length, + processCount, + processes: processesData, + remainingProcesses: result.remainingProcesses, + cpuActivity, + }; +} diff --git a/src/profile-query/formatters/thread-info.ts b/src/profile-query/formatters/thread-info.ts new file mode 100644 index 0000000000..6b998f6a45 --- /dev/null +++ b/src/profile-query/formatters/thread-info.ts @@ -0,0 +1,493 @@ +/* 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/. */ + +import { + getSelectedThreadIndexes, + getAllCommittedRanges, +} from 'firefox-profiler/selectors/url-state'; +import { + getCategories, + getDefaultCategory, + getProfile, +} from 'firefox-profiler/selectors/profile'; +import { printSliceTree, collectSliceTree } from '../cpu-activity'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import type { + ThreadInfoResult, + ThreadSamplesResult, + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + ThreadFunctionsResult, + FunctionFilterOptions, + TopFunctionInfo, +} from '../types'; +import { + extractFunctionData, + formatFunctionNameWithLibrary, +} from '../function-list'; +import { collectCallTree } from './call-tree'; +import type { CallTreeCollectionOptions } from './call-tree'; +import { + computeCallTreeTimings, + getCallTree, + computeCallNodeSelfAndSummary, +} from 'firefox-profiler/profile-logic/call-tree'; +import { getInvertedCallNodeInfo } from 'firefox-profiler/profile-logic/profile-data'; +import type { Store } from '../../types/store'; +import type { TimestampManager } from '../timestamps'; +import type { ThreadMap } from '../thread-map'; +import type { FunctionMap } from '../function-map'; +import type { CallNodePath } from 'firefox-profiler/types'; + +export function formatThreadInfo( + store: Store, + timestampManager: TimestampManager, + threadMap: ThreadMap, + threadHandle?: string +): string { + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + const threadSelectors = getThreadSelectors(threadIndexes); + const thread = threadSelectors.getRawThread(state); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const cptuActivity = threadSelectors.getRangeFilteredActivitySlices(state); + const cpuActivityLines = + cptuActivity !== null ? printSliceTree(cptuActivity, timestampManager) : []; + + return `\ +Name: ${friendlyThreadName} +Created at: ${timestampManager.nameForTimestamp(thread.registerTime)} +Ended at: ${thread.unregisterTime !== null ? timestampManager.nameForTimestamp(thread.unregisterTime) : 'still alive at end of recording'} + +This thread contains ${thread.samples.length} samples and ${thread.markers.length} markers. + +CPU activity over time: +${cpuActivityLines.join('\n')} +`; +} + +/** + * Collect thread info as structured data. + */ +export function collectThreadInfo( + store: Store, + timestampManager: TimestampManager, + threadMap: ThreadMap, + threadHandle?: string +): ThreadInfoResult { + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + const threadSelectors = getThreadSelectors(threadIndexes); + const thread = threadSelectors.getRawThread(state); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const cpuActivitySlices = + threadSelectors.getRangeFilteredActivitySlices(state); + const cpuActivity = + cpuActivitySlices !== null + ? collectSliceTree(cpuActivitySlices, timestampManager) + : null; + + const actualThreadHandle = + threadHandle ?? threadMap.handleForThreadIndexes(threadIndexes); + + return { + type: 'thread-info', + threadHandle: actualThreadHandle, + name: thread.name, + friendlyName: friendlyThreadName, + createdAt: thread.registerTime, + createdAtName: timestampManager.nameForTimestamp(thread.registerTime), + endedAt: thread.unregisterTime, + endedAtName: + thread.unregisterTime !== null + ? timestampManager.nameForTimestamp(thread.unregisterTime) + : null, + sampleCount: thread.samples.length, + markerCount: thread.markers.length, + cpuActivity, + }; +} + +/** + * Collect thread samples data in structured format. + */ +export function collectThreadSamples( + store: Store, + threadMap: ThreadMap, + functionMap: FunctionMap, + threadHandle?: string +): ThreadSamplesResult { + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + const threadHandleDisplay = threadMap.handleForThreadIndexes(threadIndexes); + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const thread = threadSelectors.getFilteredThread(state); + const libs = getProfile(state).libs; + + // Get call trees for analysis + const functionListTree = threadSelectors.getFunctionListTree(state); + const callTree = threadSelectors.getCallTree(state); + + // Extract function data + const functions = extractFunctionData(functionListTree, thread, libs); + + // Sort by total and take top 50 + const sortedByTotal = functions + .slice() + .sort((a, b) => b.total - a.total) + .slice(0, 50); + + // Sort by self and take top 50 + const sortedBySelf = functions + .slice() + .sort((a, b) => b.self - a.self) + .slice(0, 50); + + // Convert top functions to structured format + const topFunctionsByTotal: TopFunctionInfo[] = sortedByTotal.map((func) => ({ + functionHandle: functionMap.handleForFunction( + threadIndexes, + func.funcIndex + ), + functionIndex: func.funcIndex, + name: func.funcName, + nameWithLibrary: func.funcName, // Already includes library from extractFunctionData + totalSamples: func.total, + totalPercentage: func.totalRelative * 100, + selfSamples: func.self, + selfPercentage: func.selfRelative * 100, + library: undefined, // Could extract from funcName if needed + })); + + const topFunctionsBySelf: TopFunctionInfo[] = sortedBySelf.map((func) => ({ + functionHandle: functionMap.handleForFunction( + threadIndexes, + func.funcIndex + ), + functionIndex: func.funcIndex, + name: func.funcName, + nameWithLibrary: func.funcName, // Already includes library from extractFunctionData + totalSamples: func.total, + totalPercentage: func.totalRelative * 100, + selfSamples: func.self, + selfPercentage: func.selfRelative * 100, + library: undefined, // Could extract from funcName if needed + })); + + // Create a map from funcIndex to function data for quick lookup + const funcMap = new Map(functions.map((f) => [f.funcIndex, f])); + + // Collect heaviest stack + const roots = callTree.getRoots(); + let heaviestStack: ThreadSamplesResult['heaviestStack'] = { + selfSamples: 0, + frameCount: 0, + frames: [], + }; + + if (roots.length > 0) { + const heaviestPath: CallNodePath = + callTree._internal.findHeaviestPathInSubtree(roots[0]); + + if (heaviestPath.length > 0) { + const callNodeInfo = callTree._callNodeInfo; + const leafNodeIndex = callNodeInfo.getCallNodeIndexFromPath(heaviestPath); + + if (leafNodeIndex !== null) { + const leafNodeData = callTree.getNodeData(leafNodeIndex); + + heaviestStack = { + selfSamples: leafNodeData.self, + frameCount: heaviestPath.length, + frames: heaviestPath.map((funcIndex) => { + const funcName = formatFunctionNameWithLibrary( + funcIndex, + thread, + libs + ); + const funcData = funcMap.get(funcIndex); + return { + funcIndex, + name: funcName, + nameWithLibrary: funcName, + totalSamples: funcData?.total ?? 0, + totalPercentage: (funcData?.totalRelative ?? 0) * 100, + selfSamples: funcData?.self ?? 0, + selfPercentage: (funcData?.selfRelative ?? 0) * 100, + }; + }), + }; + } + } + } + + return { + type: 'thread-samples', + threadHandle: threadHandleDisplay, + friendlyThreadName, + topFunctionsByTotal, + topFunctionsBySelf, + heaviestStack, + }; +} + +/** + * Collect thread samples bottom-up data in structured format. + * Shows the inverted call tree (callers of hot functions). + */ +export function collectThreadSamplesBottomUp( + store: Store, + threadMap: ThreadMap, + functionMap: FunctionMap, + threadHandle?: string, + callTreeOptions?: CallTreeCollectionOptions +): ThreadSamplesBottomUpResult { + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + const threadHandleDisplay = threadMap.handleForThreadIndexes(threadIndexes); + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const thread = threadSelectors.getFilteredThread(state); + + // Collect inverted call tree + let invertedCallTree = null; + try { + const callNodeInfo = threadSelectors.getCallNodeInfo(state); + const categories = getCategories(state); + const defaultCategory = getDefaultCategory(state); + const weightType = threadSelectors.getWeightTypeForCallTree(state); + + const samples = threadSelectors.getPreviewFilteredCtssSamples(state); + const sampleIndexToCallNodeIndex = + threadSelectors.getSampleIndexToNonInvertedCallNodeIndexForFilteredThread( + state + ); + + const callNodeSelfAndSummary = computeCallNodeSelfAndSummary( + samples, + sampleIndexToCallNodeIndex, + callNodeInfo.getCallNodeTable().length + ); + + const invertedCallNodeInfo = getInvertedCallNodeInfo( + callNodeInfo, + defaultCategory, + thread.funcTable.length + ); + + const invertedTimings = computeCallTreeTimings( + invertedCallNodeInfo, + callNodeSelfAndSummary + ); + + const invertedTree = getCallTree( + thread, + invertedCallNodeInfo, + categories, + samples, + invertedTimings, + weightType + ); + + // Note: Bottom-up tree uses the same threadIndexes to generate function handles + const libs = getProfile(state).libs; + invertedCallTree = collectCallTree( + invertedTree, + functionMap, + threadIndexes, + libs, + callTreeOptions + ); + } catch (_e) { + // Inverted tree creation failed, leave as null + } + + return { + type: 'thread-samples-bottom-up', + threadHandle: threadHandleDisplay, + friendlyThreadName, + invertedCallTree, + }; +} + +/** + * Collect thread samples top-down data in structured format. + * Shows the regular call tree (top-down view of hot paths). + */ +export function collectThreadSamplesTopDown( + store: Store, + threadMap: ThreadMap, + functionMap: FunctionMap, + threadHandle?: string, + callTreeOptions?: CallTreeCollectionOptions +): ThreadSamplesTopDownResult { + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + const threadHandleDisplay = threadMap.handleForThreadIndexes(threadIndexes); + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const callTree = threadSelectors.getCallTree(state); + const libs = getProfile(state).libs; + + // Collect regular call tree + const regularCallTree = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + callTreeOptions + ); + + return { + type: 'thread-samples-top-down', + threadHandle: threadHandleDisplay, + friendlyThreadName, + regularCallTree, + }; +} + +/** + * Collect thread functions data in structured format. + * Lists all functions with their CPU percentages, supporting search and filtering. + */ +export function collectThreadFunctions( + store: Store, + threadMap: ThreadMap, + functionMap: FunctionMap, + threadHandle?: string, + filterOptions?: FunctionFilterOptions +): ThreadFunctionsResult { + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + const threadHandleDisplay = threadMap.handleForThreadIndexes(threadIndexes); + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const thread = threadSelectors.getFilteredThread(state); + const libs = getProfile(state).libs; + + // Get function list tree + const functionListTree = threadSelectors.getFunctionListTree(state); + + // Extract function data + const allFunctions = extractFunctionData(functionListTree, thread, libs); + const totalFunctionCount = allFunctions.length; + + // Check if we're zoomed (have committed ranges) + const committedRanges = getAllCommittedRanges(state); + const isZoomed = committedRanges.length > 0; + + // If zoomed, get full profile total samples for percentage calculation + // We can compute this from any function in allFunctions that has a non-zero totalRelative + // Formula: fullTotalSamples = total / totalRelative + // But since totalRelative is based on current view, we need the UNzoomed totalRelative + // Simpler approach: The raw thread has all samples - count them directly + let fullProfileTotalSamples: number | null = null; + if (isZoomed) { + // Get the unfiltered thread to count total samples + const rawThread = threadSelectors.getRawThread(state); + fullProfileTotalSamples = rawThread.samples.length; + } + + // Apply filters + let filteredFunctions = allFunctions; + + // Filter by search string (case-insensitive substring match) + if (filterOptions?.searchString) { + const searchLower = filterOptions.searchString.toLowerCase(); + filteredFunctions = filteredFunctions.filter((func) => + func.funcName.toLowerCase().includes(searchLower) + ); + } + + // Filter by minimum self time percentage + if (filterOptions?.minSelf !== undefined) { + const minSelfFraction = filterOptions.minSelf / 100; + filteredFunctions = filteredFunctions.filter( + (func) => func.selfRelative >= minSelfFraction + ); + } + + // Sort by self time (descending) + filteredFunctions.sort((a, b) => b.self - a.self); + + // Apply limit + const limit = filterOptions?.limit ?? filteredFunctions.length; + const limitedFunctions = filteredFunctions.slice(0, limit); + + // Convert to structured format + const functions: ThreadFunctionsResult['functions'] = limitedFunctions.map( + (func) => { + const nameWithLibrary = func.funcName; + // Extract library name if present (format: "library!function") + const bangIndex = nameWithLibrary.indexOf('!'); + const library = + bangIndex !== -1 ? nameWithLibrary.substring(0, bangIndex) : undefined; + const name = + bangIndex !== -1 + ? nameWithLibrary.substring(bangIndex + 1) + : nameWithLibrary; + + // Get full profile percentages if zoomed + let fullSelfPercentage: number | undefined; + let fullTotalPercentage: number | undefined; + if (fullProfileTotalSamples !== null) { + // Calculate percentages relative to full profile + fullSelfPercentage = (func.self / fullProfileTotalSamples) * 100; + fullTotalPercentage = (func.total / fullProfileTotalSamples) * 100; + } + + return { + functionHandle: functionMap.handleForFunction( + threadIndexes, + func.funcIndex + ), + functionIndex: func.funcIndex, + name, + nameWithLibrary, + selfSamples: func.self, + selfPercentage: func.selfRelative * 100, + totalSamples: func.total, + totalPercentage: func.totalRelative * 100, + library, + fullSelfPercentage, + fullTotalPercentage, + }; + } + ); + + return { + type: 'thread-functions', + threadHandle: threadHandleDisplay, + friendlyThreadName, + totalFunctionCount, + filteredFunctionCount: filteredFunctions.length, + filters: filterOptions + ? { + searchString: filterOptions.searchString, + minSelf: filterOptions.minSelf, + limit: filterOptions.limit, + } + : undefined, + functions, + }; +} diff --git a/src/profile-query/function-list.ts b/src/profile-query/function-list.ts new file mode 100644 index 0000000000..7d3a2ce65b --- /dev/null +++ b/src/profile-query/function-list.ts @@ -0,0 +1,525 @@ +/* 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/. */ + +import type { Thread, Lib, ThreadIndex } from 'firefox-profiler/types'; +import type { FunctionMap } from './function-map'; + +export type FunctionData = { + funcName: string; + funcIndex: number; + total: number; + self: number; + totalRelative: number; + selfRelative: number; +}; + +export type FunctionListStats = { + omittedCount: number; + maxTotal: number; + maxSelf: number; + sumSelf: number; +}; + +export type FormattedFunctionList = { + title: string; + lines: string[]; + stats: FunctionListStats | null; +}; + +/** + * A tree node representing a segment of a function name that can be truncated. + */ +type TruncNode = { + type: 'text' | 'nested'; + text: string; // For text nodes, the actual text. For nested, empty. + openBracket?: string; // '(' or '<' for nested nodes + closeBracket?: string; // ')' or '>' for nested nodes + children: TruncNode[]; // Child nodes (for nested nodes) +}; + +/** + * Parse a function name into a tree structure. + * Each nested section (templates, parameters) becomes a tree node that can be collapsed. + */ +function parseFunctionNameTree(name: string): TruncNode[] { + const stack: TruncNode[][] = [[]]; // Stack of node lists + let currentText = ''; + + const flushText = () => { + if (currentText) { + stack[stack.length - 1].push({ + type: 'text', + text: currentText, + children: [], + }); + currentText = ''; + } + }; + + for (let i = 0; i < name.length; i++) { + const char = name[i]; + + if (char === '<' || char === '(') { + flushText(); + + // Create a new nested node + const nestedNode: TruncNode = { + type: 'nested', + text: '', + openBracket: char, + closeBracket: char === '<' ? '>' : ')', + children: [], + }; + + // Add to current level + stack[stack.length - 1].push(nestedNode); + + // Push a new level for the nested content + stack.push(nestedNode.children); + } else if (char === '>' || char === ')') { + flushText(); + + // Pop back to parent level + if (stack.length > 1) { + stack.pop(); + } + } else { + currentText += char; + } + } + + flushText(); + return stack[0]; +} + +/** + * Render a tree of nodes to a string. + */ +function renderTree(nodes: TruncNode[]): string { + return nodes + .map((node) => { + if (node.type === 'text') { + return node.text; + } + // Nested node + const inner = renderTree(node.children); + return `${node.openBracket}${inner}${node.closeBracket}`; + }) + .join(''); +} + +/** + * Calculate the length of a tree if fully rendered. + */ +function treeLength(nodes: TruncNode[]): number { + return nodes.reduce((len, node) => { + if (node.type === 'text') { + return len + node.text.length; + } + // Nested: brackets + children + return len + 2 + treeLength(node.children); // 2 for open/close brackets + }, 0); +} + +/** + * Truncate a tree to fit within maxLength characters. + * Collapses nested nodes to `<...>` or `(...)` when needed. + */ +function truncateTree(nodes: TruncNode[], maxLength: number): string { + if (treeLength(nodes) <= maxLength) { + return renderTree(nodes); + } + + let result = ''; + + for (const node of nodes) { + const spaceLeft = maxLength - result.length; + if (spaceLeft <= 0) { + break; + } + + if (node.type === 'text') { + if (node.text.length <= spaceLeft) { + result += node.text; + } else { + // Truncate text, trying to break at :: for namespaces + const parts = node.text.split('::'); + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + (i < parts.length - 1 ? '::' : ''); + if (result.length + part.length <= maxLength) { + result += part; + } else { + break; + } + } + break; + } + } else { + // Nested node + const fullNested = renderTree(node.children); + const fullWithBrackets = `${node.openBracket}${fullNested}${node.closeBracket}`; + const collapsed = `${node.openBracket}...${node.closeBracket}`; + + if (fullWithBrackets.length <= spaceLeft) { + // Full content fits + result += fullWithBrackets; + } else if (collapsed.length <= spaceLeft) { + // Try to recursively truncate children + const availableForChildren = spaceLeft - 2; // 2 for brackets + const truncatedChildren = truncateTree( + node.children, + availableForChildren + ); + + if (truncatedChildren.length <= availableForChildren) { + result += `${node.openBracket}${truncatedChildren}${node.closeBracket}`; + } else { + // Just collapse + result += collapsed; + } + } else { + // Can't even fit collapsed version + break; + } + } + } + + return result; +} + +/** + * Find the last top-level `::` separator in a tree (not inside any nesting). + * Returns the index in the nodes array and position within that text node. + */ +function findLastTopLevelSeparator( + nodes: TruncNode[] +): { nodeIndex: number; position: number } | null { + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i]; + if (node.type === 'text') { + const lastColons = node.text.lastIndexOf('::'); + if (lastColons !== -1) { + return { nodeIndex: i, position: lastColons }; + } + } + } + return null; +} + +/** + * Intelligently truncate a function name, preserving context and function name. + * Handles library prefixes (e.g., "nvoglv64.dll!functionName") by processing + * only the function name portion. + */ +export function truncateFunctionName( + functionName: string, + maxLength: number +): string { + if (functionName.length <= maxLength) { + return functionName; + } + + // Check if there's a library prefix (e.g., "nvoglv64.dll!functionName") + const bangIndex = functionName.indexOf('!'); + let libraryPrefix = ''; + let funcPart = functionName; + + if (bangIndex !== -1) { + libraryPrefix = functionName.substring(0, bangIndex + 1); // Include the '!' + funcPart = functionName.substring(bangIndex + 1); + + // Calculate space available for function name after prefix + const availableForFunc = maxLength - libraryPrefix.length; + + if (availableForFunc <= 10) { + // Library prefix is too long, fall back to simple truncation + return functionName.substring(0, maxLength - 3) + '...'; + } + + // If the function part fits, return it + if (funcPart.length <= availableForFunc) { + return functionName; + } + + // Otherwise, truncate the function part smartly + maxLength = availableForFunc; + } + + // Parse into tree + const tree = parseFunctionNameTree(funcPart); + + // Find the last top-level :: separator to split prefix/suffix + const separator = findLastTopLevelSeparator(tree); + + if (separator === null) { + // No namespace separator - just truncate the whole thing + return libraryPrefix + truncateTree(tree, maxLength); + } + + // Split into prefix (context) and suffix (function name) + const { nodeIndex, position } = separator; + const sepNode = tree[nodeIndex]; + + // Build prefix nodes + const prefixNodes: TruncNode[] = tree.slice(0, nodeIndex); + if (position > 0) { + // Include part of the separator node before :: + prefixNodes.push({ + type: 'text', + text: sepNode.text.substring(0, position + 2), // Include the :: + children: [], + }); + } else { + prefixNodes.push({ + type: 'text', + text: '::', + children: [], + }); + } + + // Build suffix nodes + const suffixNodes: TruncNode[] = []; + const remainingText = sepNode.text.substring(position + 2); + if (remainingText) { + suffixNodes.push({ + type: 'text', + text: remainingText, + children: [], + }); + } + suffixNodes.push(...tree.slice(nodeIndex + 1)); + + const prefixLen = treeLength(prefixNodes); + const suffixLen = treeLength(suffixNodes); + + // Check if both fit + if (prefixLen + suffixLen <= maxLength) { + return libraryPrefix + funcPart; + } + + // Allocate space: prioritize suffix (function name), up to 70% + const maxSuffixLen = Math.floor(maxLength * 0.7); + let suffixAlloc: number; + let prefixAlloc: number; + + if (suffixLen <= maxSuffixLen) { + // Suffix fits fully, give rest to prefix + suffixAlloc = suffixLen; + prefixAlloc = maxLength - suffixLen; + } else { + // Both need truncation - give at least 30% to prefix for context + prefixAlloc = Math.floor(maxLength * 0.3); + suffixAlloc = maxLength - prefixAlloc; + } + + const truncatedPrefix = truncateTree(prefixNodes, prefixAlloc); + const truncatedSuffix = truncateTree(suffixNodes, suffixAlloc); + + return libraryPrefix + truncatedPrefix + truncatedSuffix; +} + +/** + * Format a function name with its library/resource name. + * Returns "libraryName!functionName" or just "functionName" if no library is available. + */ +export function formatFunctionNameWithLibrary( + funcIndex: number, + thread: Thread, + libs: Lib[] +): string { + const funcName = thread.stringTable.getString( + thread.funcTable.name[funcIndex] + ); + const resourceIndex = thread.funcTable.resource[funcIndex]; + + // If there's no resource or it's -1, just return the function name + if (resourceIndex === -1) { + return funcName; + } + + // Get the resource name + const resourceName = thread.stringTable.getString( + thread.resourceTable.name[resourceIndex] + ); + + // Get the library name if available + const libIndex = thread.resourceTable.lib[resourceIndex]; + if (libIndex !== null && libs) { + const lib = libs[libIndex]; + // Use the library name (e.g., "nvoglv64.dll") rather than full path + const libName = lib.name; + return `${libName}!${funcName}`; + } + + // Fall back to resource name if no library + if (resourceName && resourceName !== funcName) { + return `${resourceName}!${funcName}`; + } + + return funcName; +} + +/** + * Extract function data from a CallTree (function list tree). + * Formats function names with library/resource information when available. + */ +export function extractFunctionData( + tree: { + getRoots(): number[]; + getNodeData(nodeIndex: number): { + total: number; + self: number; + totalRelative: number; + selfRelative: number; + }; + }, + thread: Thread, + libs: Lib[] +): FunctionData[] { + const roots = tree.getRoots(); + return roots.map((nodeIndex) => { + const data = tree.getNodeData(nodeIndex); + // The node index IS the function index for function list trees + const formattedName = formatFunctionNameWithLibrary( + nodeIndex, + thread, + libs + ); + return { + ...data, + funcName: formattedName, + funcIndex: nodeIndex, // Preserve the function index + }; + }); +} + +/** + * Sort functions by total time (descending). + */ +export function sortByTotal(functions: FunctionData[]): FunctionData[] { + return [...functions].sort((a, b) => b.total - a.total); +} + +/** + * Sort functions by self time (descending). + */ +export function sortBySelf(functions: FunctionData[]): FunctionData[] { + return [...functions].sort((a, b) => b.self - a.self); +} + +/** + * Format a single function entry with optional handle. + */ +function formatFunctionEntry( + func: FunctionData, + sortKey: 'total' | 'self', + threadIndexes: Set, + functionMap: FunctionMap +): string { + const totalPct = (func.totalRelative * 100).toFixed(1); + const selfPct = (func.selfRelative * 100).toFixed(1); + const totalCount = Math.round(func.total); + const selfCount = Math.round(func.self); + + // Truncate function name to 120 characters (smart truncation preserves meaning) + const displayName = truncateFunctionName(func.funcName, 120); + + // Generate handle if FunctionMap is provided + const handle = functionMap.handleForFunction(threadIndexes, func.funcIndex); + const handleStr = `${handle}. `; + + if (sortKey === 'total') { + return ` ${handleStr}${displayName} - total: ${totalCount} (${totalPct}%), self: ${selfCount} (${selfPct}%)`; + } + return ` ${handleStr}${displayName} - self: ${selfCount} (${selfPct}%), total: ${totalCount} (${totalPct}%)`; +} + +/** + * Compute statistics for omitted functions. + */ +function computeOmittedStats( + omittedFunctions: FunctionData[] +): FunctionListStats | null { + if (omittedFunctions.length === 0) { + return null; + } + + const maxTotal = Math.max(...omittedFunctions.map((f) => f.total)); + const maxSelf = Math.max(...omittedFunctions.map((f) => f.self)); + const sumSelf = omittedFunctions.reduce((sum, f) => sum + f.self, 0); + + return { + omittedCount: omittedFunctions.length, + maxTotal, + maxSelf, + sumSelf, + }; +} + +/** + * Format a list of functions with a limit, showing statistics for omitted entries. + */ +export function formatFunctionList( + title: string, + functions: FunctionData[], + limit: number, + sortKey: 'total' | 'self', + threadIndexes: Set, + functionMap: FunctionMap +): FormattedFunctionList { + const displayedFunctions = functions.slice(0, limit); + const omittedFunctions = functions.slice(limit); + + const lines = displayedFunctions.map((func) => + formatFunctionEntry(func, sortKey, threadIndexes, functionMap) + ); + + const stats = computeOmittedStats(omittedFunctions); + + if (stats) { + lines.push(''); + lines.push( + ` ... (${stats.omittedCount} more functions omitted, ` + + `max total: ${Math.round(stats.maxTotal)}, ` + + `max self: ${Math.round(stats.maxSelf)}, ` + + `sum of self: ${Math.round(stats.sumSelf)})` + ); + } + + return { + title, + lines, + stats, + }; +} + +/** + * Create both top function lists (by total and by self). + */ +export function createTopFunctionLists( + functions: FunctionData[], + limit: number, + threadIndexes: Set, + functionMap: FunctionMap +): { byTotal: FormattedFunctionList; bySelf: FormattedFunctionList } { + const byTotal = formatFunctionList( + 'Top Functions (by total time)', + sortByTotal(functions), + limit, + 'total', + threadIndexes, + functionMap + ); + + const bySelf = formatFunctionList( + 'Top Functions (by self time)', + sortBySelf(functions), + limit, + 'self', + threadIndexes, + functionMap + ); + + return { byTotal, bySelf }; +} diff --git a/src/profile-query/function-map.ts b/src/profile-query/function-map.ts new file mode 100644 index 0000000000..ef002c04a0 --- /dev/null +++ b/src/profile-query/function-map.ts @@ -0,0 +1,69 @@ +/* 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/. */ + +import { getThreadsKey } from 'firefox-profiler/profile-logic/profile-data'; +import type { + ThreadIndex, + IndexIntoFuncTable, + ThreadsKey, +} from 'firefox-profiler/types'; + +/** + * Represents a function identified by its thread and function index. + */ +export type FunctionId = { + threadIndexes: Set; + threadsKey: ThreadsKey; + funcIndex: IndexIntoFuncTable; +}; + +/** + * Maps function handles (like "f-1", "f-2") to (threadIndex, funcIndex) pairs. + * This provides a user-friendly way to reference functions in the CLI. + * + * Since each thread has its own funcTable, we need to store both the thread + * index and the function index to uniquely identify a function. + */ +export class FunctionMap { + _handleToFunction: Map = new Map(); + _nextHandleId: number = 1; + + /** + * Get or create a handle for a function. + * Returns the same handle if called multiple times with the same function. + */ + handleForFunction( + threadIndexes: Set, + funcIndex: IndexIntoFuncTable + ): string { + // Check if we already have a handle for this function + const threadsKey = getThreadsKey(threadIndexes); + for (const [handle, funcId] of this._handleToFunction.entries()) { + if (funcId.threadsKey === threadsKey && funcId.funcIndex === funcIndex) { + return handle; + } + } + + // Create a new handle + const handle = 'f-' + this._nextHandleId++; + this._handleToFunction.set(handle, { + threadIndexes, + threadsKey, + funcIndex, + }); + return handle; + } + + /** + * Look up a function by its handle. + * Throws an error if the handle is unknown. + */ + functionForHandle(functionHandle: string): FunctionId { + const funcId = this._handleToFunction.get(functionHandle); + if (funcId === undefined) { + throw new Error(`Unknown function ${functionHandle}`); + } + return funcId; + } +} diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts new file mode 100644 index 0000000000..b680cbf15c --- /dev/null +++ b/src/profile-query/index.ts @@ -0,0 +1,679 @@ +/* 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/. */ + +/* + * This implements a library for querying the contents of a profile. + * + * To use it it first needs to be built: + * yarn build-profile-query + * + * Then it can be used from an interactive node session: + * + * % node + * > const { ProfileQuerier } = (await import('./dist/profile-query.js')).default; + * undefined + * > const p1 = await ProfileQuerier.load("/Users/mstange/Downloads/merged-profile.json.gz"); + * > const p2 = await ProfileQuerier.load("https://profiler.firefox.com/from-url/http%3A%2F%2Fexample.com%2Fprofile.json/"); + * > const p3 = await ProfileQuerier.load("https://share.firefox.dev/4oLEjCw"); + */ + +import { + getProfile, + getProfileRootRange, +} from 'firefox-profiler/selectors/profile'; +import { + getAllCommittedRanges, + getSelectedThreadIndexes, +} from 'firefox-profiler/selectors/url-state'; +import { + commitRange, + popCommittedRanges, + changeSelectedThreads, +} from '../actions/profile-view'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { TimestampManager } from './timestamps'; +import { ThreadMap } from './thread-map'; +import { FunctionMap } from './function-map'; +import { MarkerMap } from './marker-map'; +import { loadProfileFromFileOrUrl } from './loader'; +import { collectProfileInfo } from './formatters/profile-info'; +import { + collectThreadInfo, + collectThreadSamples, + collectThreadSamplesTopDown, + collectThreadSamplesBottomUp, + collectThreadFunctions, +} from './formatters/thread-info'; +import { + collectThreadMarkers, + collectMarkerStack, + collectMarkerInfo, +} from './formatters/marker-info'; +import { parseTimeValue } from './time-range-parser'; +import type { + StatusResult, + SessionContext, + WithContext, + FunctionExpandResult, + FunctionInfoResult, + ViewRangeResult, + ThreadInfoResult, + MarkerStackResult, + MarkerInfoResult, + ProfileInfoResult, + ThreadSamplesResult, + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + ThreadMarkersResult, + ThreadFunctionsResult, + MarkerFilterOptions, + FunctionFilterOptions, +} from './types'; +import type { CallTreeCollectionOptions } from './formatters/call-tree'; + +import type { StartEndRange } from 'firefox-profiler/types'; +import type { Store } from '../types/store'; + +export class ProfileQuerier { + _store: Store; + _processIndexMap: Map; + _timestampManager: TimestampManager; + _threadMap: ThreadMap; + _functionMap: FunctionMap; + _markerMap: MarkerMap; + + constructor(store: Store, rootRange: StartEndRange) { + this._store = store; + this._processIndexMap = new Map(); + this._timestampManager = new TimestampManager(rootRange); + this._threadMap = new ThreadMap(); + this._functionMap = new FunctionMap(); + this._markerMap = new MarkerMap(); + + // Build process index map + const state = this._store.getState(); + const profile = getProfile(state); + const uniquePids = Array.from(new Set(profile.threads.map((t) => t.pid))); + uniquePids.forEach((pid, index) => { + this._processIndexMap.set(pid, index); + }); + } + + static async load(filePathOrUrl: string): Promise { + const { store, rootRange } = await loadProfileFromFileOrUrl(filePathOrUrl); + return new ProfileQuerier(store, rootRange); + } + + async profileInfo(): Promise> { + const result = await collectProfileInfo( + this._store, + this._timestampManager, + this._threadMap, + this._processIndexMap + ); + return { ...result, context: this._getContext() }; + } + + async threadInfo( + threadHandle?: string + ): Promise> { + const result = await collectThreadInfo( + this._store, + this._timestampManager, + this._threadMap, + threadHandle + ); + return { ...result, context: this._getContext() }; + } + + async threadSamples( + threadHandle?: string + ): Promise> { + const result = await collectThreadSamples( + this._store, + this._threadMap, + this._functionMap, + threadHandle + ); + return { ...result, context: this._getContext() }; + } + + async threadSamplesTopDown( + threadHandle?: string, + callTreeOptions?: CallTreeCollectionOptions + ): Promise> { + const result = await collectThreadSamplesTopDown( + this._store, + this._threadMap, + this._functionMap, + threadHandle, + callTreeOptions + ); + return { ...result, context: this._getContext() }; + } + + async threadSamplesBottomUp( + threadHandle?: string, + callTreeOptions?: CallTreeCollectionOptions + ): Promise> { + const result = await collectThreadSamplesBottomUp( + this._store, + this._threadMap, + this._functionMap, + threadHandle, + callTreeOptions + ); + return { ...result, context: this._getContext() }; + } + + /** + * Push a view range selection (commit a range). + * Supports multiple formats: + * - Marker handle: "m-1" (uses marker's start/end times) + * - Timestamp names: "ts-6,ts-7" + * - Seconds: "2.7,3.1" (default if no suffix) + * - Milliseconds: "2700ms,3100ms" + * - Percentage: "10%,20%" + */ + async pushViewRange(rangeName: string): Promise { + const state = this._store.getState(); + const rootRange = getProfileRootRange(state); + const zeroAt = rootRange.start; + + let startTimestamp: number; + let endTimestamp: number; + let markerInfo: ViewRangeResult['markerInfo'] = undefined; + + // Check if it's a marker handle (e.g., "m-1") + if (rangeName.startsWith('m-') && !rangeName.includes(',')) { + // Look up the marker + const { threadIndexes, markerIndex } = + this._markerMap.markerForHandle(rangeName); + const threadSelectors = getThreadSelectors(threadIndexes); + const fullMarkerList = threadSelectors.getFullMarkerList(state); + const marker = fullMarkerList[markerIndex]; + + if (!marker) { + throw new Error(`Marker ${rangeName} not found`); + } + + // Check if marker is an interval marker (has end time) + if (marker.end === null) { + throw new Error( + `Marker ${rangeName} is an instant marker (no duration). Only interval markers can be used for zoom ranges.` + ); + } + + startTimestamp = marker.start; + endTimestamp = marker.end; + + // Store marker info for enhanced output + const threadHandle = + this._threadMap.handleForThreadIndexes(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + markerInfo = { + markerHandle: rangeName, + markerName: marker.name, + threadHandle, + threadName: friendlyThreadName, + }; + } else { + // Split at comma for traditional range format + const parts = rangeName.split(',').map((s) => s.trim()); + if (parts.length !== 2) { + throw new Error( + `Invalid range format: "${rangeName}". Expected a marker handle (e.g., "m-1") or two comma-separated values (e.g., "2.7,3.1" or "ts-6,ts-7")` + ); + } + + // Parse start and end values (supports multiple formats) + const parsedStart = parseTimeValue(parts[0], rootRange); + const parsedEnd = parseTimeValue(parts[1], rootRange); + + // If parseTimeValue returns null, it's a timestamp name - look it up + startTimestamp = + parsedStart ?? + (() => { + const ts = this._timestampManager.timestampForName(parts[0]); + if (ts === null) { + throw new Error(`Unknown timestamp name: "${parts[0]}"`); + } + return ts; + })(); + + endTimestamp = + parsedEnd ?? + (() => { + const ts = this._timestampManager.timestampForName(parts[1]); + if (ts === null) { + throw new Error(`Unknown timestamp name: "${parts[1]}"`); + } + return ts; + })(); + } + + // Get or create timestamp names for display + const startName = this._timestampManager.nameForTimestamp(startTimestamp); + const endName = this._timestampManager.nameForTimestamp(endTimestamp); + + // Convert absolute timestamps to relative timestamps. + // commitRange expects timestamps relative to the profile start (zeroAt), + // but we have absolute timestamps. The getCommittedRange selector will + // add zeroAt back to them. + const relativeStart = startTimestamp - zeroAt; + const relativeEnd = endTimestamp - zeroAt; + + // Dispatch the commitRange action with relative timestamps + this._store.dispatch(commitRange(relativeStart, relativeEnd)); + + // Get the zoom depth after pushing + const newState = this._store.getState(); + const committedRanges = getAllCommittedRanges(newState); + const zoomDepth = committedRanges.length; + + // Calculate duration + const duration = endTimestamp - startTimestamp; + + const message = `Pushed view range: ${startName} (${this._timestampManager.timestampString(startTimestamp)}) to ${endName} (${this._timestampManager.timestampString(endTimestamp)})`; + + return { + type: 'view-range', + action: 'push', + range: { + start: startTimestamp, + startName, + end: endTimestamp, + endName, + }, + message, + duration, + zoomDepth, + markerInfo, + }; + } + + /** + * Pop the most recent view range selection. + */ + async popViewRange(): Promise { + const state = this._store.getState(); + const committedRanges = getAllCommittedRanges(state); + + if (committedRanges.length === 0) { + throw new Error('No view ranges to pop'); + } + + // Pop the last committed range (index = length - 1) + const poppedIndex = committedRanges.length - 1; + this._store.dispatch(popCommittedRanges(poppedIndex)); + + const poppedRange = committedRanges[poppedIndex]; + + // Convert relative timestamps back to absolute timestamps + // committedRanges stores timestamps relative to the profile start (zeroAt) + const rootRange = getProfileRootRange(state); + const zeroAt = rootRange.start; + const absoluteStart = poppedRange.start + zeroAt; + const absoluteEnd = poppedRange.end + zeroAt; + + const startName = this._timestampManager.nameForTimestamp(absoluteStart); + const endName = this._timestampManager.nameForTimestamp(absoluteEnd); + + const message = `Popped view range: ${startName} (${this._timestampManager.timestampString(absoluteStart)}) to ${endName} (${this._timestampManager.timestampString(absoluteEnd)})`; + + return { + type: 'view-range', + action: 'pop', + range: { + start: absoluteStart, + startName, + end: absoluteEnd, + endName, + }, + message, + }; + } + + /** + * Clear all view range selections (return to root view). + */ + async clearViewRange(): Promise { + const state = this._store.getState(); + const committedRanges = getAllCommittedRanges(state); + + if (committedRanges.length === 0) { + throw new Error('No view ranges to clear'); + } + + // Pop all committed ranges (index 0 pops from the first one) + this._store.dispatch(popCommittedRanges(0)); + + const rootRange = getProfileRootRange(state); + const startName = this._timestampManager.nameForTimestamp(rootRange.start); + const endName = this._timestampManager.nameForTimestamp(rootRange.end); + + const message = `Cleared all view ranges, returned to full profile: ${startName} (${this._timestampManager.timestampString(rootRange.start)}) to ${endName} (${this._timestampManager.timestampString(rootRange.end)})`; + + return { + type: 'view-range', + action: 'pop', + range: { + start: rootRange.start, + startName, + end: rootRange.end, + endName, + }, + message, + }; + } + + /** + * Select one or more threads by handle (e.g., "t-0" or "t-0,t-1,t-2"). + */ + async threadSelect(threadHandle: string): Promise { + const threadIndexes = this._threadMap.threadIndexesForHandle(threadHandle); + + // Change the selected threads in the Redux store + this._store.dispatch(changeSelectedThreads(threadIndexes)); + + const state = this._store.getState(); + const profile = getProfile(state); + + if (threadIndexes.size === 1) { + const threadIndex = Array.from(threadIndexes)[0]; + const thread = profile.threads[threadIndex]; + return `Selected thread: ${threadHandle} (${thread.name})`; + } + + const names = Array.from(threadIndexes) + .map((idx) => profile.threads[idx].name) + .join(', '); + return `Selected ${threadIndexes.size} threads: ${threadHandle} (${names})`; + } + + /** + * Get current session context for display in command outputs. + * This is a lightweight version of getStatus() that includes only + * the current view range (not the full stack). + */ + private _getContext(): SessionContext { + const state = this._store.getState(); + const profile = getProfile(state); + const rootRange = getProfileRootRange(state); + const committedRanges = getAllCommittedRanges(state); + const selectedThreadIndexes = getSelectedThreadIndexes(state); + + // Get selected threads info + const selectedThreadHandle = + selectedThreadIndexes.size > 0 + ? this._threadMap.handleForThreadIndexes(selectedThreadIndexes) + : null; + + const selectedThreads = Array.from(selectedThreadIndexes).map( + (threadIndex) => ({ + threadIndex, + name: profile.threads[threadIndex].name, + }) + ); + + // Get current (most recent) view range if any + const zeroAt = rootRange.start; + let currentViewRange = null; + if (committedRanges.length > 0) { + const range = committedRanges[committedRanges.length - 1]; + const absoluteStart = range.start + zeroAt; + const absoluteEnd = range.end + zeroAt; + const startName = this._timestampManager.nameForTimestamp(absoluteStart); + const endName = this._timestampManager.nameForTimestamp(absoluteEnd); + currentViewRange = { + start: absoluteStart, + startName, + end: absoluteEnd, + endName, + }; + } + + return { + selectedThreadHandle, + selectedThreads, + currentViewRange, + rootRange: { + start: rootRange.start, + end: rootRange.end, + }, + }; + } + + /** + * Get current session status including selected threads and view ranges. + */ + async getStatus(): Promise { + const state = this._store.getState(); + const profile = getProfile(state); + const rootRange = getProfileRootRange(state); + const committedRanges = getAllCommittedRanges(state); + const selectedThreadIndexes = getSelectedThreadIndexes(state); + + // Get selected threads info + const selectedThreadHandle = + selectedThreadIndexes.size > 0 + ? this._threadMap.handleForThreadIndexes(selectedThreadIndexes) + : null; + + const selectedThreads = Array.from(selectedThreadIndexes).map( + (threadIndex) => ({ + threadIndex, + name: profile.threads[threadIndex].name, + }) + ); + + // Collect view ranges + const zeroAt = rootRange.start; + const viewRanges = committedRanges.map((range) => { + const absoluteStart = range.start + zeroAt; + const absoluteEnd = range.end + zeroAt; + const startName = this._timestampManager.nameForTimestamp(absoluteStart); + const endName = this._timestampManager.nameForTimestamp(absoluteEnd); + return { + start: absoluteStart, + startName, + end: absoluteEnd, + endName, + }; + }); + + return { + type: 'status', + selectedThreadHandle, + selectedThreads, + viewRanges, + rootRange: { + start: rootRange.start, + end: rootRange.end, + }, + }; + } + + /** + * Expand a function handle to show the full untruncated name. + */ + async functionExpand( + functionHandle: string + ): Promise> { + const state = this._store.getState(); + const profile = getProfile(state); + + // Look up the function + const { threadIndexes, funcIndex } = + this._functionMap.functionForHandle(functionHandle); + + const threadSelectors = getThreadSelectors(threadIndexes); + const thread = threadSelectors.getFilteredThread(state); + const funcName = thread.stringTable.getString( + thread.funcTable.name[funcIndex] + ); + const resourceIndex = thread.funcTable.resource[funcIndex]; + + // Get library prefix if available + let library: string | undefined; + if (resourceIndex !== -1 && thread.resourceTable) { + const libIndex = thread.resourceTable.lib[resourceIndex]; + if (libIndex !== null && libIndex !== undefined && profile.libs) { + const lib = profile.libs[libIndex]; + library = lib.name; + } + } + + const fullName = library ? `${library}!${funcName}` : funcName; + const threadHandle = this._threadMap.handleForThreadIndexes(threadIndexes); + + return { + type: 'function-expand', + functionHandle, + funcIndex, + threadHandle, + name: funcName, + fullName, + library, + context: this._getContext(), + }; + } + + /** + * Show detailed information about a function. + */ + async functionInfo( + functionHandle: string + ): Promise> { + const state = this._store.getState(); + const profile = getProfile(state); + + // Look up the function + const { threadIndexes, funcIndex } = + this._functionMap.functionForHandle(functionHandle); + + const threadSelectors = getThreadSelectors(threadIndexes); + const thread = threadSelectors.getFilteredThread(state); + const threadHandle = this._threadMap.handleForThreadIndexes(threadIndexes); + + const funcName = thread.stringTable.getString( + thread.funcTable.name[funcIndex] + ); + const resourceIndex = thread.funcTable.resource[funcIndex]; + const isJS = thread.funcTable.isJS[funcIndex]; + const relevantForJS = thread.funcTable.relevantForJS[funcIndex]; + + let resource: FunctionInfoResult['resource']; + let library: FunctionInfoResult['library']; + let libraryName: string | undefined; + + // Add resource info if available + if (resourceIndex !== -1 && thread.resourceTable) { + const resourceName = thread.stringTable.getString( + thread.resourceTable.name[resourceIndex] + ); + resource = { + name: resourceName, + index: resourceIndex, + }; + + const libIndex = thread.resourceTable.lib[resourceIndex]; + if ( + libIndex !== null && + libIndex !== undefined && + libIndex >= 0 && + profile.libs + ) { + const lib = profile.libs[libIndex]; + libraryName = lib.name; + library = { + name: lib.name, + path: lib.path, + debugName: lib.debugName, + debugPath: lib.debugPath, + breakpadId: lib.breakpadId, + }; + } + } + + const fullName = libraryName ? `${libraryName}!${funcName}` : funcName; + + return { + type: 'function-info', + functionHandle, + funcIndex, + threadHandle, + threadName: thread.name, + name: funcName, + fullName, + isJS, + relevantForJS, + resource, + library, + context: this._getContext(), + }; + } + + /** + * List markers for a thread with aggregated statistics. + */ + async threadMarkers( + threadHandle?: string, + filterOptions?: MarkerFilterOptions + ): Promise> { + const result = await collectThreadMarkers( + this._store, + this._threadMap, + this._markerMap, + threadHandle, + filterOptions + ); + return { ...result, context: this._getContext() }; + } + + /** + * List all functions for a thread with their CPU percentages. + * Supports filtering by search string, minimum self time, and limit. + */ + async threadFunctions( + threadHandle?: string, + filterOptions?: FunctionFilterOptions + ): Promise> { + const result = await collectThreadFunctions( + this._store, + this._threadMap, + this._functionMap, + threadHandle, + filterOptions + ); + return { ...result, context: this._getContext() }; + } + + /** + * Show detailed information about a specific marker. + */ + async markerInfo( + markerHandle: string + ): Promise> { + const result = await collectMarkerInfo( + this._store, + this._markerMap, + this._threadMap, + markerHandle + ); + return { ...result, context: this._getContext() }; + } + + async markerStack( + markerHandle: string + ): Promise> { + const result = await collectMarkerStack( + this._store, + this._markerMap, + this._threadMap, + markerHandle + ); + return { ...result, context: this._getContext() }; + } +} diff --git a/src/profile-query/loader.ts b/src/profile-query/loader.ts new file mode 100644 index 0000000000..d3b0cdbc69 --- /dev/null +++ b/src/profile-query/loader.ts @@ -0,0 +1,131 @@ +/* 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/. */ + +import * as fs from 'fs'; + +import createStore from '../app-logic/create-store'; +import { unserializeProfileOfArbitraryFormat } from '../profile-logic/process-profile'; +import { finalizeProfileView, loadProfile } from '../actions/receive-profile'; +import { getProfileRootRange } from 'firefox-profiler/selectors/profile'; +import { + extractProfileUrlFromProfilerUrl, + fetchProfile, +} from '../utils/profile-fetch'; +import type { TemporaryError } from '../utils/errors'; +import type { Store } from '../types/store'; +import type { StartEndRange } from 'firefox-profiler/types'; + +/** + * Helper function to detect if the input is a URL + */ +function isUrl(input: string): boolean { + return input.startsWith('http://') || input.startsWith('https://'); +} + +/** + * Helper function to follow redirects and get the final URL. + * This is useful for short URLs like https://share.firefox.dev/4oLEjCw + */ +async function followRedirects(url: string): Promise { + const response = await fetch(url, { + method: 'HEAD', + redirect: 'follow', + }); + return response.url; +} + +export interface LoadResult { + store: Store; + rootRange: StartEndRange; +} + +/** + * Load a profile from a file path or URL. + * Returns a store and root range that can be used to construct a ProfileQuerier. + */ +export async function loadProfileFromFileOrUrl( + filePathOrUrl: string +): Promise { + const store = createStore(); + console.log(`Loading profile from ${filePathOrUrl}`); + + if (isUrl(filePathOrUrl)) { + // Handle URL input + let finalUrl = filePathOrUrl; + + // If it's a profiler.firefox.com URL (or short URL that redirects to one), + // extract the actual profile URL from it + if ( + filePathOrUrl.includes('profiler.firefox.com') || + filePathOrUrl.includes('share.firefox.dev') + ) { + // Follow redirects for short URLs + if (filePathOrUrl.includes('share.firefox.dev')) { + console.log('Following redirect from short URL...'); + finalUrl = await followRedirects(filePathOrUrl); + console.log(`Redirected to: ${finalUrl}`); + } + + // Extract the profile URL from the profiler.firefox.com URL + const profileUrl = extractProfileUrlFromProfilerUrl(finalUrl); + if (profileUrl) { + console.log(`Extracted profile URL: ${profileUrl}`); + finalUrl = profileUrl; + } else { + throw new Error( + `Unable to extract profile URL from profiler URL: ${finalUrl}` + ); + } + } + + // Fetch the profile using shared utility + console.log(`Fetching profile from ${finalUrl}`); + const result = await fetchProfile({ + url: finalUrl, + onTemporaryError: (e: TemporaryError) => { + if (e.attempt) { + console.log(`Retry ${e.attempt.count}/${e.attempt.total}...`); + } + }, + }); + + // Check if this is a zip file - not yet supported in CLI + if (result.responseType === 'ZIP') { + throw new Error( + 'Zip files are not yet supported in the CLI. ' + + 'Please extract the profile from the zip file first, or use the web interface at profiler.firefox.com' + ); + } + + // Extract the profile data + const profile = await unserializeProfileOfArbitraryFormat(result.profile); + if (profile === undefined) { + throw new Error('Unable to parse the profile.'); + } + + await store.dispatch(loadProfile(profile, {}, true)); + await store.dispatch(finalizeProfileView()); + const state = store.getState(); + const rootRange = getProfileRootRange(state); + return { store, rootRange }; + } + + // Handle file path input + // Read the raw bytes from the file. It might be a JSON file, but it could also + // be a binary file, e.g. a .json.gz file, or any of the binary formats supported + // by our importers. + const bytes = fs.readFileSync(filePathOrUrl, null); + + // Load the profile. + const profile = await unserializeProfileOfArbitraryFormat(bytes); + if (profile === undefined) { + throw new Error('Unable to parse the profile.'); + } + + await store.dispatch(loadProfile(profile, {}, true)); + await store.dispatch(finalizeProfileView()); + const state = store.getState(); + const rootRange = getProfileRootRange(state); + return { store, rootRange }; +} diff --git a/src/profile-query/marker-map.ts b/src/profile-query/marker-map.ts new file mode 100644 index 0000000000..ef464a860c --- /dev/null +++ b/src/profile-query/marker-map.ts @@ -0,0 +1,72 @@ +/* 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/. */ + +import { getThreadsKey } from 'firefox-profiler/profile-logic/profile-data'; +import type { + ThreadIndex, + MarkerIndex, + ThreadsKey, +} from 'firefox-profiler/types'; + +/** + * Represents a marker identified by its thread and marker index. + */ +export type MarkerId = { + threadIndexes: Set; + threadsKey: ThreadsKey; + markerIndex: MarkerIndex; +}; + +/** + * Maps marker handles (like "m-1", "m-2") to (threadIndex, markerIndex) pairs. + * This provides a user-friendly way to reference markers in the CLI. + * + * Since each thread has its own marker list, we need to store both the thread + * index and the marker index to uniquely identify a marker. + */ +export class MarkerMap { + _handleToMarker: Map = new Map(); + _nextHandleId: number = 1; + + /** + * Get or create a handle for a marker. + * Returns the same handle if called multiple times with the same marker. + */ + handleForMarker( + threadIndexes: Set, + markerIndex: MarkerIndex + ): string { + // Check if we already have a handle for this marker + const threadsKey = getThreadsKey(threadIndexes); + for (const [handle, markerId] of this._handleToMarker.entries()) { + if ( + markerId.threadsKey === threadsKey && + markerId.markerIndex === markerIndex + ) { + return handle; + } + } + + // Create a new handle + const handle = 'm-' + this._nextHandleId++; + this._handleToMarker.set(handle, { + threadIndexes, + threadsKey, + markerIndex, + }); + return handle; + } + + /** + * Look up a marker by its handle. + * Throws an error if the handle is unknown. + */ + markerForHandle(markerHandle: string): MarkerId { + const markerId = this._handleToMarker.get(markerHandle); + if (markerId === undefined) { + throw new Error(`Unknown marker ${markerHandle}`); + } + return markerId; + } +} diff --git a/src/profile-query/process-thread-list.ts b/src/profile-query/process-thread-list.ts new file mode 100644 index 0000000000..ca8fd9a6d3 --- /dev/null +++ b/src/profile-query/process-thread-list.ts @@ -0,0 +1,178 @@ +/* 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/. */ + +export type ThreadInfo = { + threadIndex: number; + name: string; + cpuMs: number; + pid: string; +}; + +export type ProcessInfo = { + pid: string; + processIndex: number; + name: string; + cpuMs: number; + threads: Array<{ threadIndex: number; name: string; cpuMs: number }>; +}; + +export type ProcessListItem = { + processIndex: number; + pid: string; + name: string; + cpuMs: number; + threads: Array<{ threadIndex: number; name: string; cpuMs: number }>; + remainingThreads?: { + count: number; + combinedCpuMs: number; + maxCpuMs: number; + }; + startTime?: number; + endTime?: number | null; +}; + +export type ProcessThreadListResult = { + processes: ProcessListItem[]; + remainingProcesses?: { + count: number; + combinedCpuMs: number; + maxCpuMs: number; + }; +}; + +/** + * Build a hierarchical list of processes and threads for display. + * + * Shows: + * - Top 5 processes by CPU time + * - Any additional processes that contain threads from the top 20 threads overall + * - For each process, shows its top threads: + * - If the process has threads in the top 20 overall, show ALL of those threads + * - Otherwise, show up to 5 threads + * - Summary of remaining threads if any + * - Summary of remaining processes if any + */ +export function buildProcessThreadList( + threads: ThreadInfo[], + processIndexMap: Map +): ProcessThreadListResult { + // Aggregate threads by process + const processCPUMap = new Map(); + + threads.forEach((thread) => { + const { pid, threadIndex, name, cpuMs } = thread; + const existing = processCPUMap.get(pid); + + if (existing) { + existing.cpuMs += cpuMs; + existing.threads.push({ threadIndex, name, cpuMs }); + } else { + const processIndex = processIndexMap.get(pid); + if (processIndex === undefined) { + throw new Error(`Process index not found for pid ${pid}`); + } + // Infer process name from first thread's process info + // In real usage, this would come from the thread's processName field + processCPUMap.set(pid, { + pid, + processIndex, + name: pid, // Will be overridden by caller + cpuMs, + threads: [{ threadIndex, name, cpuMs }], + }); + } + }); + + // Sort threads within each process by CPU + processCPUMap.forEach((processInfo) => { + processInfo.threads.sort((a, b) => b.cpuMs - a.cpuMs); + }); + + // Get all processes sorted by CPU + const allProcesses = Array.from(processCPUMap.values()); + allProcesses.sort((a, b) => b.cpuMs - a.cpuMs); + + // Get top 5 processes by CPU + const top5ProcessPids = new Set(allProcesses.slice(0, 5).map((p) => p.pid)); + + // Get top 20 threads overall + const allThreadsSorted = [...threads].sort((a, b) => b.cpuMs - a.cpuMs); + const top20Threads = allThreadsSorted.slice(0, 20); + const top20ThreadPids = new Set(top20Threads.map((t) => t.pid)); + + // Build a set of threadIndexes that are in the top 20 + const top20ThreadIndexes = new Set(top20Threads.map((t) => t.threadIndex)); + + // Determine which processes to show + const processesToShow = allProcesses.filter( + (p) => top5ProcessPids.has(p.pid) || top20ThreadPids.has(p.pid) + ); + + // Build the result list + const result: ProcessListItem[] = processesToShow.map((processInfo) => { + const { pid, processIndex, name, cpuMs, threads: allThreads } = processInfo; + + // Separate threads into top-20 and others + const top20ThreadsInProcess = allThreads.filter((t) => + top20ThreadIndexes.has(t.threadIndex) + ); + const otherThreads = allThreads.filter( + (t) => !top20ThreadIndexes.has(t.threadIndex) + ); + + // Show all top-20 threads, plus fill up to 5 with other threads if needed + const threadsToShow = [...top20ThreadsInProcess]; + const remainingSlots = Math.max(0, 5 - threadsToShow.length); + threadsToShow.push(...otherThreads.slice(0, remainingSlots)); + + // Calculate remaining threads summary + const remainingThreads = otherThreads.slice(remainingSlots); + let remainingThreadsInfo: ProcessListItem['remainingThreads'] = undefined; + + if (remainingThreads.length > 0) { + const combinedCpuMs = remainingThreads.reduce( + (sum, t) => sum + t.cpuMs, + 0 + ); + const maxCpuMs = Math.max(...remainingThreads.map((t) => t.cpuMs)); + remainingThreadsInfo = { + count: remainingThreads.length, + combinedCpuMs, + maxCpuMs, + }; + } + + return { + processIndex, + pid, + name, + cpuMs, + threads: threadsToShow, + remainingThreads: remainingThreadsInfo, + }; + }); + + // Calculate remaining processes summary + const remainingProcesses = allProcesses.slice(processesToShow.length); + let remainingProcessesInfo: ProcessThreadListResult['remainingProcesses'] = + undefined; + + if (remainingProcesses.length > 0) { + const combinedCpuMs = remainingProcesses.reduce( + (sum, p) => sum + p.cpuMs, + 0 + ); + const maxCpuMs = Math.max(...remainingProcesses.map((p) => p.cpuMs)); + remainingProcessesInfo = { + count: remainingProcesses.length, + combinedCpuMs, + maxCpuMs, + }; + } + + return { + processes: result, + remainingProcesses: remainingProcessesInfo, + }; +} diff --git a/src/profile-query/thread-map.ts b/src/profile-query/thread-map.ts new file mode 100644 index 0000000000..09efc233b5 --- /dev/null +++ b/src/profile-query/thread-map.ts @@ -0,0 +1,47 @@ +/* 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/. */ + +import type { ThreadIndex } from 'firefox-profiler/types'; + +/** + * Maps thread handles (like "t-0", "t-1") to thread indices. + * This provides a user-friendly way to reference threads in the CLI. + * Supports multi-thread handles like "t-4,t-2,t-6" for selecting multiple threads. + */ +export class ThreadMap { + _map: Map = new Map(); + + handleForThreadIndex(threadIndex: ThreadIndex): string { + const handle = 't-' + threadIndex; + if (!this._map.has(handle)) { + this._map.set(handle, threadIndex); + } + return handle; + } + + threadIndexForHandle(threadHandle: string): ThreadIndex { + const threadIndex = this._map.get(threadHandle); + if (threadIndex === undefined) { + throw new Error(`Unknown thread ${threadHandle}`); + } + return threadIndex; + } + + threadIndexesForHandle(threadHandle: string): Set { + const handles = threadHandle.split(',').map((s) => s.trim()); + const indices = handles.map((handle) => { + const idx = this._map.get(handle); + if (idx === undefined) { + throw new Error(`Unknown thread ${handle}`); + } + return idx; + }); + return new Set(indices); + } + + handleForThreadIndexes(threadIndexes: Set): string { + const sorted = Array.from(threadIndexes).sort((a, b) => a - b); + return sorted.map((idx) => this.handleForThreadIndex(idx)).join(','); + } +} diff --git a/src/profile-query/time-range-parser.ts b/src/profile-query/time-range-parser.ts new file mode 100644 index 0000000000..3266d1762d --- /dev/null +++ b/src/profile-query/time-range-parser.ts @@ -0,0 +1,63 @@ +/* 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/. */ + +import type { StartEndRange } from 'firefox-profiler/types'; + +/** + * Parse a time value from the push-range command. + * Supports multiple formats: + * - Timestamp names: "ts-6" (returns null, caller should look up in timestamp manager) + * - Seconds: "2.7" or "2.7s" (relative to profile start) + * - Milliseconds: "2700ms" (relative to profile start) + * - Percentage: "10%" (percentage through profile duration) + * + * Returns absolute timestamp in milliseconds, or null if it's a timestamp name. + */ +export function parseTimeValue( + value: string, + rootRange: StartEndRange +): number | null { + // Check if it's a timestamp name (starts with "ts") + if (value.startsWith('ts')) { + // Return null to signal caller should look it up + return null; + } + + // Check if it's a percentage + if (value.endsWith('%')) { + const percent = parseFloat(value.slice(0, -1)); + if (isNaN(percent)) { + throw new Error(`Invalid percentage: "${value}"`); + } + const duration = rootRange.end - rootRange.start; + return rootRange.start + (percent / 100) * duration; + } + + // Check if it's milliseconds + if (value.endsWith('ms')) { + const ms = parseFloat(value.slice(0, -2)); + if (isNaN(ms)) { + throw new Error(`Invalid milliseconds: "${value}"`); + } + return rootRange.start + ms; + } + + // Check if it's seconds with 's' suffix + if (value.endsWith('s')) { + const seconds = parseFloat(value.slice(0, -1)); + if (isNaN(seconds)) { + throw new Error(`Invalid seconds: "${value}"`); + } + return rootRange.start + seconds * 1000; + } + + // Default: treat as seconds (no suffix) + const seconds = parseFloat(value); + if (isNaN(seconds)) { + throw new Error( + `Invalid time value: "${value}". Expected timestamp name (ts-X), seconds (2.7), milliseconds (2700ms), or percentage (10%)` + ); + } + return rootRange.start + seconds * 1000; +} diff --git a/src/profile-query/timestamps.ts b/src/profile-query/timestamps.ts new file mode 100644 index 0000000000..24d4d65dcf --- /dev/null +++ b/src/profile-query/timestamps.ts @@ -0,0 +1,312 @@ +/* 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/. */ + +/** + * TimestampManager provides compact, hierarchical names for timestamps to make + * them LLM-friendly and token-efficient. This allows LLMs to reference specific + * time points when using ProfileQuerier (e.g., for range selections). + * + * Naming scheme: + * - In-range timestamps [start, end]: "ts-" prefix (e.g., ts-0, ts-K, ts-gK) + * - Before-start timestamps: "ts<" prefix with exponential buckets (ts<0, ts<1, ...) + * - After-end timestamps: "ts>" prefix with exponential buckets (ts>0, ts>1, ...) + * + * The hierarchical algorithm creates shorter names for timestamps that are + * referenced early, with names growing longer as you drill down between existing + * marks. This keeps token usage low while maintaining precision. + */ + +import type { StartEndRange } from 'firefox-profiler/types'; +import { bisectionRightByKey } from 'firefox-profiler/utils/bisect'; +import { formatTimestamp } from 'firefox-profiler/utils/format-numbers'; + +/** + * Build the character alphabet used for timestamp names. + * Order: 0-9, a-z, A-Z (62 characters total). + */ +function _makeChars(): string[] { + const chars = []; + for (let i = 0; i < 10; i++) { + chars.push('' + i); + } + const aLower = 'a'.charCodeAt(0); + const aUpper = 'A'.charCodeAt(0); + for (let i = 0; i < 26; i++) { + chars.push(String.fromCharCode(aLower + i)); + chars.push(String.fromCharCode(aUpper + i)); + } + + return chars; +} + +function assert(condition: boolean) { + if (!condition) { + throw new Error('assert failed'); + } +} + +/** + * Item represents a node in the hierarchical timestamp tree. Each item + * corresponds to a specific timestamp and has an index in its level's + * character space (0-61). Items lazily create children as timestamps + * between existing marks are requested. + */ +class Item { + index: number; + timestamp: number; + + // Children are created on-demand and ordered by timestamp. + _children: Item[] | null = null; + + constructor(index: number, start: number) { + this.index = index; + this.timestamp = start; + } + + /** + * Get a hierarchical name for a timestamp within this item's range. + * + * Algorithm: + * 1. If timestamp matches an existing mark, return its name + * 2. Find the two adjacent marks that bracket the timestamp + * 3. If marks are adjacent (indices differ by 1), recurse into the left mark + * 4. Otherwise, interpolate to find a new index and insert a new mark + * + * This ensures timestamps requested early get shorter names, with names + * growing longer as you drill down between existing marks. + */ + nameForTimestamp(ts: number, end: number, prefix: string): string { + const start = this.timestamp; + if (ts < start || ts > end) { + throw new Error('out of range'); + } + if (ts === start) { + return prefix; + } + // Lazily initialize with boundary marks at indices 0 and MARKS_PER_LEVEL-1. + if (this._children === null) { + this._children = [new Item(0, start), new Item(MARKS_PER_LEVEL - 1, end)]; + } + // Binary search to find the left mark that brackets this timestamp. + const i = + bisectionRightByKey(this._children, ts, (item) => item.timestamp) - 1; + assert(i >= 0); + assert(i + 1 < this._children.length); + const left = this._children[i]; + const right = this._children[i + 1]; + assert(ts >= left.timestamp); + assert(ts < right.timestamp); + const leftIndex = left.index; + const rightIndex = right.index; + const indexDelta = rightIndex - leftIndex; + const rightTimestamp = right.timestamp; + // If marks are adjacent, recurse into the left mark's subrange. + if (indexDelta === 1) { + return left.nameForTimestamp( + ts, + rightTimestamp, + prefix + CHARS[leftIndex] + ); + } + // Interpolate to find a new index between the two marks. + const leftTimestamp = left.timestamp; + const relativeTimestamp = ts - leftTimestamp; + const timestampDelta = rightTimestamp - leftTimestamp; + const itemIndex = + leftIndex + + 1 + + Math.floor((relativeTimestamp / timestampDelta) * (indexDelta - 1)); + assert(itemIndex > leftIndex); + assert(itemIndex < rightIndex); + // Insert the new mark and return its name. + const item = new Item(itemIndex, ts); + this._children.splice(i + 1, 0, item); + return prefix + CHARS[itemIndex]; + } +} + +// Character alphabet: 0-9, a-z, A-Z (62 characters) +const CHARS = _makeChars(); +const MARKS_PER_LEVEL = CHARS.length; + +/** + * TimestampManager creates compact, hierarchical names for timestamps. + * + * Example names for range [1000, 2000]: + * - 1000 → "ts-0" (range start) + * - 2000 → "ts-Z" (range end) + * - 1500 → "ts-K" (middle of range) + * - 1000.1 → "ts-04" (between ts-0 and ts-1, drills into ts-0's subrange) + * - 500 → "ts<0K" (before range start, in first bucket before-range) + * - 2500 → "ts>0K" (after range end, in first bucket after-range) + * + * Out-of-bounds timestamps use exponentially doubling buckets: + * - ts<0: [start - 1×length, start] + * - ts<1: [start - 2×length, start - 1×length] + * - ts<2: [start - 4×length, start - 2×length] + * - ts buckets extending to the right. + */ +export class TimestampManager { + _rootRangeStart: number; + _rootRangeEnd: number; + _rootRangeLength: number; + _mainTree: Item; + // Trees for exponentially-spaced buckets before/after the main range. + // Keys are bucket numbers (0, 1, 2, ...), created on-demand. + _beforeBuckets: Map = new Map(); + _afterBuckets: Map = new Map(); + // Reverse lookup: timestamp name → actual timestamp value. + // Only contains names that have been returned by nameForTimestamp(). + _nameToTimestamp: Map = new Map(); + + constructor(rootRange: StartEndRange) { + this._rootRangeStart = rootRange.start; + this._rootRangeEnd = rootRange.end; + this._rootRangeLength = rootRange.end - rootRange.start; + this._mainTree = new Item(0, rootRange.start); + } + + /** + * Get a compact name for a timestamp. Names are minted on-demand and + * cached for reverse lookup. + */ + nameForTimestamp(ts: number): string { + // Check cache first for exact matches. + for (const [name, cachedTs] of this._nameToTimestamp.entries()) { + if (cachedTs === ts) { + return name; + } + } + + let name: string; + + // Handle special boundary cases. + if (ts === this._rootRangeStart) { + name = 'ts-0'; + } else if (ts === this._rootRangeEnd) { + name = 'ts-Z'; + } else if (ts < this._rootRangeStart) { + // Before-start: find the appropriate exponential bucket. + const distance = this._rootRangeStart - ts; + const bucketNum = this._getBucketNumber(distance); + const bucket = this._getOrCreateBeforeBucket(bucketNum); + const bucketEnd = this._getBeforeBucketEnd(bucketNum); + name = bucket.nameForTimestamp(ts, bucketEnd, `ts<${bucketNum}`); + } else if (ts > this._rootRangeEnd) { + // After-end: find the appropriate exponential bucket. + const distance = ts - this._rootRangeEnd; + const bucketNum = this._getBucketNumber(distance); + const bucket = this._getOrCreateAfterBucket(bucketNum); + const bucketEnd = this._getAfterBucketEnd(bucketNum); + name = bucket.nameForTimestamp(ts, bucketEnd, `ts>${bucketNum}`); + } else { + // In-range: use main tree. + name = this._mainTree.nameForTimestamp(ts, this._rootRangeEnd, 'ts-'); + } + + // Cache for reverse lookup. + this._nameToTimestamp.set(name, ts); + return name; + } + + /** + * Reverse lookup: get the timestamp for a name that was previously + * returned by nameForTimestamp(). Returns null if the name is unknown. + */ + timestampForName(name: string): number | null { + return this._nameToTimestamp.get(name) ?? null; + } + + /** + * Format a timestamp as a human-readable string relative to range start. + */ + timestampString(ts: number): string { + return formatTimestamp(ts - this._rootRangeStart); + } + + /** + * Calculate which bucket number a timestamp belongs to based on distance + * from the range boundary. Buckets double in size exponentially. + * + * Bucket 0: distance <= 1×length + * Bucket 1: 1×length < distance <= 2×length + * Bucket 2: 2×length < distance <= 4×length + * Bucket n: 2^(n-1)×length < distance <= 2^n×length + */ + _getBucketNumber(distance: number): number { + const ratio = distance / this._rootRangeLength; + if (ratio <= 1) { + return 0; + } + return Math.ceil(Math.log2(ratio)); + } + + /** + * Get the start timestamp for a before-bucket. + * Bucket n covers [start - 2^n×length, start - 2^(n-1)×length]. + */ + _getBeforeBucketStart(bucketNum: number): number { + const distanceFromStart = Math.pow(2, bucketNum) * this._rootRangeLength; + return this._rootRangeStart - distanceFromStart; + } + + /** + * Get the end timestamp for a before-bucket. + */ + _getBeforeBucketEnd(bucketNum: number): number { + if (bucketNum === 0) { + return this._rootRangeStart; + } + const distanceFromStart = + Math.pow(2, bucketNum - 1) * this._rootRangeLength; + return this._rootRangeStart - distanceFromStart; + } + + /** + * Get the start timestamp for an after-bucket. + * Bucket n covers [end + 2^(n-1)×length, end + 2^n×length]. + */ + _getAfterBucketStart(bucketNum: number): number { + if (bucketNum === 0) { + return this._rootRangeEnd; + } + const distanceFromEnd = Math.pow(2, bucketNum - 1) * this._rootRangeLength; + return this._rootRangeEnd + distanceFromEnd; + } + + /** + * Get the end timestamp for an after-bucket. + */ + _getAfterBucketEnd(bucketNum: number): number { + const distanceFromEnd = Math.pow(2, bucketNum) * this._rootRangeLength; + return this._rootRangeEnd + distanceFromEnd; + } + + /** + * Get or create an Item tree for a before-bucket. + */ + _getOrCreateBeforeBucket(bucketNum: number): Item { + let bucket = this._beforeBuckets.get(bucketNum); + if (!bucket) { + const bucketStart = this._getBeforeBucketStart(bucketNum); + bucket = new Item(0, bucketStart); + this._beforeBuckets.set(bucketNum, bucket); + } + return bucket; + } + + /** + * Get or create an Item tree for an after-bucket. + */ + _getOrCreateAfterBucket(bucketNum: number): Item { + let bucket = this._afterBuckets.get(bucketNum); + if (!bucket) { + const bucketStart = this._getAfterBucketStart(bucketNum); + bucket = new Item(0, bucketStart); + this._afterBuckets.set(bucketNum, bucket); + } + return bucket; + } +} diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts new file mode 100644 index 0000000000..0b2863ec98 --- /dev/null +++ b/src/profile-query/types.ts @@ -0,0 +1,428 @@ +/* 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/. */ + +/** + * Shared types for profile querying. + * These types are used by both profile-query (the library) and profile-query-cli. + */ + +// ===== Utility types ===== + +export type TopMarker = { + handle: string; + label: string; + start: number; + duration?: number; + hasStack?: boolean; +}; + +export type FunctionDisplayInfo = { + name: string; + nameWithLibrary: string; + library?: string; +}; + +// ===== Filter Options ===== + +export type MarkerFilterOptions = { + searchString?: string; + minDuration?: number; // Minimum duration in milliseconds + maxDuration?: number; // Maximum duration in milliseconds + category?: string; // Filter by category name + hasStack?: boolean; // Only show markers with stack traces + limit?: number; // Limit the number of markers in aggregation (not output lines) + groupBy?: string; // Grouping strategy (e.g., "type,name" or "type,field:eventType") + autoGroup?: boolean; // Automatically determine grouping based on field variance +}; + +export type FunctionFilterOptions = { + searchString?: string; // Substring search in function names + minSelf?: number; // Minimum self time percentage (0-100) + limit?: number; // Limit the number of functions in output +}; + +// ===== Session Context ===== +// Context information included in all command results for persistent display + +export type SessionContext = { + selectedThreadHandle: string | null; // Combined handle like "t-0" or "t-0,t-1,t-2" + selectedThreads: Array<{ + threadIndex: number; + name: string; + }>; + currentViewRange: { + start: number; + startName: string; + end: number; + endName: string; + } | null; // null if viewing full profile + rootRange: { + start: number; + end: number; + }; +}; + +/** + * Wrapper type that adds session context to any result type. + */ +export type WithContext = T & { context: SessionContext }; + +// ===== Status Command ===== + +export type StatusResult = { + type: 'status'; + selectedThreadHandle: string | null; // Combined handle like "t-0" or "t-0,t-1,t-2" + selectedThreads: Array<{ + threadIndex: number; + name: string; + }>; + viewRanges: Array<{ + start: number; + startName: string; + end: number; + endName: string; + }>; + rootRange: { + start: number; + end: number; + }; +}; + +// ===== Function Commands ===== + +export type FunctionExpandResult = { + type: 'function-expand'; + functionHandle: string; + funcIndex: number; + threadHandle: string; + name: string; + fullName: string; + library?: string; +}; + +export type FunctionInfoResult = { + type: 'function-info'; + functionHandle: string; + funcIndex: number; + threadHandle: string; + threadName: string; + name: string; + fullName: string; + isJS: boolean; + relevantForJS: boolean; + resource?: { + name: string; + index: number; + }; + library?: { + name: string; + path: string; + debugName?: string; + debugPath?: string; + breakpadId?: string; + }; +}; + +// ===== View Range Commands ===== + +export type ViewRangeResult = { + type: 'view-range'; + action: 'push' | 'pop'; + range: { + start: number; + startName: string; + end: number; + endName: string; + }; + message: string; + // Enhanced information for better UX (optional, only present for 'push' action) + duration?: number; // Duration in milliseconds + zoomDepth?: number; // Current zoom stack depth + markerInfo?: { + // Present if zoomed to a marker + markerHandle: string; + markerName: string; + threadHandle: string; + threadName: string; + }; +}; + +// ===== Thread Commands ===== + +export type ThreadInfoResult = { + type: 'thread-info'; + threadHandle: string; + name: string; + friendlyName: string; + createdAt: number; + createdAtName: string; + endedAt: number | null; + endedAtName: string | null; + sampleCount: number; + markerCount: number; + cpuActivity: Array<{ + startTime: number; + startTimeName: string; + startTimeStr: string; + endTime: number; + endTimeName: string; + endTimeStr: string; + cpuMs: number; + depthLevel: number; + }> | null; +}; + +export type TopFunctionInfo = FunctionDisplayInfo & { + functionHandle: string; + functionIndex: number; + totalSamples: number; + totalPercentage: number; + selfSamples: number; + selfPercentage: number; +}; + +export type ThreadSamplesResult = { + type: 'thread-samples'; + threadHandle: string; + friendlyThreadName: string; + topFunctionsByTotal: TopFunctionInfo[]; + topFunctionsBySelf: TopFunctionInfo[]; + heaviestStack: { + selfSamples: number; + frameCount: number; + frames: Array< + FunctionDisplayInfo & { + totalSamples: number; + totalPercentage: number; + selfSamples: number; + selfPercentage: number; + } + >; + }; +}; + +export type ThreadSamplesTopDownResult = { + type: 'thread-samples-top-down'; + threadHandle: string; + friendlyThreadName: string; + regularCallTree: CallTreeNode; +}; + +export type ThreadSamplesBottomUpResult = { + type: 'thread-samples-bottom-up'; + threadHandle: string; + friendlyThreadName: string; + invertedCallTree: CallTreeNode | null; +}; + +/** + * Scoring strategy for selecting which call tree nodes to include. + * The score determines node priority, with the constraint that child score ≤ parent score. + */ +export type CallTreeScoringStrategy = + | 'exponential-0.95' // totalPercentage * (0.95 ^ depth) - slow decay + | 'exponential-0.9' // totalPercentage * (0.9 ^ depth) - medium decay + | 'exponential-0.8' // totalPercentage * (0.8 ^ depth) - fast decay + | 'harmonic-0.1' // totalPercentage / (1 + 0.1 * depth) - very slow + | 'harmonic-0.5' // totalPercentage / (1 + 0.5 * depth) - medium + | 'harmonic-1.0' // totalPercentage / (1 + depth) - standard harmonic + | 'percentage-only'; // totalPercentage - no depth penalty + +export type CallTreeNode = FunctionDisplayInfo & { + callNodeIndex?: number; // Optional for root node + functionHandle?: string; // Optional for root node + functionIndex?: number; // Optional for root node + totalSamples: number; + totalPercentage: number; + selfSamples: number; + selfPercentage: number; + /** Original depth in tree before collapsing single-child chains */ + originalDepth: number; + children: CallTreeNode[]; + /** Information about truncated children, if any were omitted */ + childrenTruncated?: { + count: number; + combinedSamples: number; + combinedPercentage: number; + maxSamples: number; + maxPercentage: number; + depth: number; // Depth where children were truncated + }; +}; + +export type ThreadMarkersResult = { + type: 'thread-markers'; + threadHandle: string; + friendlyThreadName: string; + totalMarkerCount: number; + filteredMarkerCount: number; + filters?: { + searchString?: string; + minDuration?: number; + maxDuration?: number; + category?: string; + hasStack?: boolean; + limit?: number; + }; + byType: Array<{ + markerName: string; + count: number; + isInterval: boolean; + durationStats?: DurationStats; + rateStats?: RateStats; + topMarkers: TopMarker[]; + subGroups?: MarkerGroupData[]; + subGroupKey?: string; + }>; + byCategory: Array<{ + categoryName: string; + categoryIndex: number; + count: number; + percentage: number; + }>; + customGroups?: MarkerGroupData[]; +}; + +export type DurationStats = { + min: number; + max: number; + avg: number; + median: number; + p95: number; + p99: number; +}; + +export type RateStats = { + markersPerSecond: number; + minGap: number; + avgGap: number; + maxGap: number; +}; + +export type MarkerGroupData = { + groupName: string; + count: number; + isInterval: boolean; + durationStats?: DurationStats; + rateStats?: RateStats; + topMarkers: TopMarker[]; + subGroups?: MarkerGroupData[]; +}; + +export type ThreadFunctionsResult = { + type: 'thread-functions'; + threadHandle: string; + friendlyThreadName: string; + totalFunctionCount: number; + filteredFunctionCount: number; + filters?: { + searchString?: string; + minSelf?: number; + limit?: number; + }; + functions: Array< + { + functionHandle: string; + selfSamples: number; + selfPercentage: number; + totalSamples: number; + totalPercentage: number; + // Optional full profile percentages (present when zoomed) + fullSelfPercentage?: number; + fullTotalPercentage?: number; + } & FunctionDisplayInfo + >; +}; + +// ===== Marker Commands ===== + +export type MarkerInfoResult = { + type: 'marker-info'; + threadHandle: string; + friendlyThreadName: string; + markerHandle: string; + markerIndex: number; + name: string; + tooltipLabel?: string; + markerType?: string; + category: { + index: number; + name: string; + }; + start: number; + end: number | null; + duration?: number; + fields?: Array<{ + key: string; + label: string; + value: any; + formattedValue: string; + }>; + schema?: { + description?: string; + }; + stack?: StackTraceData; +}; + +export type MarkerStackResult = { + type: 'marker-stack'; + threadHandle: string; + friendlyThreadName: string; + markerHandle: string; + markerIndex: number; + markerName: string; + stack: StackTraceData | null; +}; + +export type StackTraceData = { + capturedAt?: number; + frames: FunctionDisplayInfo[]; + truncated: boolean; +}; + +// ===== Profile Commands ===== + +export type ProfileInfoResult = { + type: 'profile-info'; + name: string; + platform: string; + threadCount: number; + processCount: number; + processes: Array<{ + processIndex: number; + pid: string; + name: string; + cpuMs: number; + startTime?: number; + startTimeName?: string; + endTime?: number | null; + endTimeName?: string | null; + threads: Array<{ + threadIndex: number; + threadHandle: string; + name: string; + cpuMs: number; + }>; + remainingThreads?: { + count: number; + combinedCpuMs: number; + maxCpuMs: number; + }; + }>; + remainingProcesses?: { + count: number; + combinedCpuMs: number; + maxCpuMs: number; + }; + cpuActivity: Array<{ + startTime: number; + startTimeName: string; + startTimeStr: string; + endTime: number; + endTimeName: string; + endTimeStr: string; + cpuMs: number; + depthLevel: number; + }> | null; +}; diff --git a/src/selectors/per-thread/thread.tsx b/src/selectors/per-thread/thread.tsx index ee5c8db852..7a5d97f5de 100644 --- a/src/selectors/per-thread/thread.tsx +++ b/src/selectors/per-thread/thread.tsx @@ -204,6 +204,25 @@ export function getBasicThreadSelectorsPerThread( } ); + /** + * Get activity slices for the range-filtered thread (respecting zoom). + * This shows CPU activity only for the samples within the committed range. + */ + const getRangeFilteredActivitySlices: Selector = + createSelector(getRangeFilteredThread, (thread) => { + const samples = thread.samples; + return samples.hasCPUDeltas + ? getSlices( + [0.05, 0.2, 0.4, 0.6, 0.8], + Float64Array.from( + samples.threadCPUPercent.subarray(0, samples.length), + (v) => v / 100 + ), + samples.time + ) + : null; + }); + /** * The CallTreeSummaryStrategy determines how the call tree summarizes the * the current thread. By default, this is done by timing, but other @@ -416,6 +435,7 @@ export function getBasicThreadSelectorsPerThread( getSamplesTable, getTracedValuesBuffer, getActivitySlices, + getRangeFilteredActivitySlices, getSamplesWeightType, getNativeAllocations, getJsAllocations, diff --git a/src/selectors/profile.ts b/src/selectors/profile.ts index 34253ae15e..ddb6986553 100644 --- a/src/selectors/profile.ts +++ b/src/selectors/profile.ts @@ -796,6 +796,22 @@ export const getCombinedThreadCPUData: Selector = + createSelector( + getAllThreadsSamplesTables, + getCommittedRange, + (samplesTables, range) => + CombinedCPU.combineCPUDataFromThreads( + samplesTables, + range.start, + range.end + ) + ); + /** * Get activity slices for the combined CPU usage across all threads. * Returns hierarchical slices showing periods of high combined CPU activity, @@ -814,6 +830,23 @@ export const getCombinedThreadActivitySlices: Selector = ); }); +/** + * Get activity slices for the combined CPU usage, filtered to the committed range. + * This respects zoom and shows only activity within the current view. + */ +export const getRangeFilteredCombinedThreadActivitySlices: Selector = + createSelector(getRangeFilteredCombinedThreadCPUData, (combinedCPU) => { + if (combinedCPU === null) { + return null; + } + const m = Math.ceil(combinedCPU.maxCpuRatio); + return getSlices( + [0.05 * m, 0.2 * m, 0.4 * m, 0.6 * m, 0.8 * m], + combinedCPU.cpuRatio, + combinedCPU.time + ); + }); + /** * Get the pages array and construct a Map of pages that we can use to get the * relationships of tabs. The constructed map is `Map`. diff --git a/src/test/unit/profile-query-cli/__snapshots__/call-tree-formatting.test.ts.snap b/src/test/unit/profile-query-cli/__snapshots__/call-tree-formatting.test.ts.snap new file mode 100644 index 0000000000..6e7e0a7fd6 --- /dev/null +++ b/src/test/unit/profile-query-cli/__snapshots__/call-tree-formatting.test.ts.snap @@ -0,0 +1,318 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`call tree formatting bottom-up view complex nested trees formats a deep call chain inverted 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-1. Idle [total: 50.0%, self: 50.0%] +└─ f-5. Loop [total: 50.0%, self: 0.0%] + f-6. Main [total: 50.0%, self: 0.0%] +f-2. Think [total: 25.0%, self: 25.0%] +└─ f-7. AI [total: 25.0%, self: 0.0%] + f-4. Tick [total: 25.0%, self: 0.0%] + f-5. Loop [total: 25.0%, self: 0.0%] + f-6. Main [total: 25.0%, self: 0.0%] +f-3. Phys [total: 25.0%, self: 25.0%] +└─ f-4. Tick [total: 25.0%, self: 0.0%] + f-5. Loop [total: 25.0%, self: 0.0%] + f-6. Main [total: 25.0%, self: 0.0%]" +`; + +exports[`call tree formatting bottom-up view complex nested trees shows which functions call a leaf function 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-1. E [total: 100.0%, self: 100.0%] +└─ f-2. D [total: 100.0%, self: 0.0%] + ├─ f-3. A [total: 33.3%, self: 0.0%] + ├─ f-4. B [total: 33.3%, self: 0.0%] + └─ f-5. C [total: 33.3%, self: 0.0%]" +`; + +exports[`call tree formatting bottom-up view different scoring strategies exponential-0.9 strategy for bottom-up 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-1. G [total: 20.0%, self: 20.0%] +└─ f-7. D [total: 20.0%, self: 0.0%] + └─ ... (1 more children: combined 20.0%, max 20.0%) +f-2. E [total: 20.0%, self: 20.0%] +└─ f-6. A [total: 20.0%, self: 0.0%] +f-3. F [total: 20.0%, self: 20.0%] +└─ f-6. A [total: 20.0%, self: 0.0%] +f-4. B [total: 20.0%, self: 20.0%] +f-5. C [total: 20.0%, self: 20.0%]" +`; + +exports[`call tree formatting bottom-up view elision bugs each parent node should have at most one elision marker 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Top-Down Call Tree: +f-1. A [total: 100.0%, self: 0.0%] +└─ f-2. B [total: 100.0%, self: 0.0%] + f-3. C [total: 100.0%, self: 0.0%] + └─ ... (1 more children: combined 100.0%, max 100.0%)" +`; + +exports[`call tree formatting bottom-up view elision bugs elided children percentages should be relative to parent, not full profile 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-1. B [total: 50.0%, self: 50.0%] +└─ ... (5 more children: combined 50.0%, max 10.0%) +f-2. D [total: 50.0%, self: 50.0%] +└─ f-3. C [total: 50.0%, self: 0.0%]" +`; + +exports[`call tree formatting bottom-up view elision bugs node whose children were never expanded must still show elision marker 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Top-Down Call Tree: +f-1. Root [total: 100.0%, self: 0.0%] +├─ f-2. A [total: 60.0%, self: 0.0%] +│ ├─ f-4. A2 [total: 10.0%, self: 10.0%] +│ └─ ... (5 more children: combined 50.0%, max 10.0%) +├─ f-3. B [total: 20.0%, self: 0.0%] +│ └─ ... (2 more children: combined 20.0%, max 10.0%) +└─ ... (2 more children: combined 20.0%, max 10.0%)" +`; + +exports[`call tree formatting bottom-up view elision bugs sibling nodes with elided children should each show their own elision marker 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Top-Down Call Tree: +f-1. A [total: 100.0%, self: 0.0%] +├─ f-2. B1 [total: 50.0%, self: 0.0%] +│ ├─ f-4. C5 [total: 10.0%, self: 10.0%] +│ └─ ... (4 more children: combined 40.0%, max 10.0%) +└─ f-3. B2 [total: 50.0%, self: 0.0%] + └─ ... (5 more children: combined 50.0%, max 10.0%)" +`; + +exports[`call tree formatting bottom-up view simple trees formats a branching tree inverted 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-1. D [total: 28.6%, self: 28.6%] +└─ f-5. A [total: 28.6%, self: 0.0%] +f-2. E [total: 28.6%, self: 28.6%] +└─ f-5. A [total: 28.6%, self: 0.0%] +f-3. B [total: 28.6%, self: 28.6%] +f-4. C [total: 14.3%, self: 14.3%]" +`; + +exports[`call tree formatting bottom-up view simple trees formats a simple linear tree inverted 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-1. D [total: 100.0%, self: 100.0%] +└─ f-2. C [total: 100.0%, self: 0.0%] + f-3. B [total: 100.0%, self: 0.0%] + f-4. A [total: 100.0%, self: 0.0%]" +`; + +exports[`call tree formatting bottom-up view trees with truncation shows elided callers at multiple levels 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-1. E [total: 25.0%, self: 25.0%] +└─ f-5. B [total: 25.0%, self: 0.0%] + f-4. A [total: 25.0%, self: 0.0%] +f-2. F [total: 25.0%, self: 25.0%] +└─ f-5. B [total: 25.0%, self: 0.0%] + f-4. A [total: 25.0%, self: 0.0%] +f-3. D [total: 25.0%, self: 25.0%] +└─ f-4. A [total: 25.0%, self: 0.0%]" +`; + +exports[`call tree formatting bottom-up view trees with truncation shows elided callers with correct percentages 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-1. B [total: 30.0%, self: 30.0%] +└─ f-4. A [total: 30.0%, self: 0.0%] +f-2. C [total: 20.0%, self: 20.0%] +└─ f-4. A [total: 20.0%, self: 0.0%] +f-3. D [total: 10.0%, self: 10.0%] +└─ ... (1 more children: combined 10.0%, max 10.0%)" +`; + +exports[`call tree formatting top-down view complex nested trees formats a complex tree with mixed branching patterns 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Top-Down Call Tree: +f-1. Main [total: 100.0%, self: 0.0%] +├─ f-2. Loop [total: 90.0%, self: 0.0%] +│ ├─ f-4. Tick [total: 40.0%, self: 0.0%] +│ │ ├─ f-11. AI [total: 20.0%, self: 0.0%] +│ │ │ f-13. Think [total: 20.0%, self: 20.0%] +│ │ └─ f-12. Phys [total: 20.0%, self: 20.0%] +│ ├─ f-5. Idle [total: 20.0%, self: 20.0%] +│ ├─ f-6. Render [total: 10.0%, self: 10.0%] +│ ├─ f-7. Rende [total: 10.0%, self: 0.0%] +│ │ f-10. Layou [total: 10.0%, self: 10.0%] +│ └─ f-8. r Render [total: 10.0%, self: 0.0%] +│ f-9. t Layout [total: 10.0%, self: 10.0%] +└─ f-3. Init [total: 10.0%, self: 10.0%]" +`; + +exports[`call tree formatting top-down view complex nested trees formats a deep nested path with branching 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Top-Down Call Tree: +f-1. A [total: 80.0%, self: 0.0%] +├─ f-3. C [total: 60.0%, self: 0.0%] +│ ├─ f-5. E [total: 40.0%, self: 0.0%] +│ │ ├─ f-7. G [total: 20.0%, self: 0.0%] +│ │ │ f-9. I [total: 20.0%, self: 20.0%] +│ │ └─ f-8. H [total: 20.0%, self: 20.0%] +│ └─ f-6. F [total: 20.0%, self: 20.0%] +└─ f-4. D [total: 20.0%, self: 20.0%] +f-2. B [total: 20.0%, self: 20.0%]" +`; + +exports[`call tree formatting top-down view different scoring strategies exponential-0.9 strategy output 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Top-Down Call Tree: +f-1. A [total: 60.0%, self: 0.0%] +├─ f-4. D [total: 20.0%, self: 0.0%] +│ f-7. G [total: 20.0%, self: 20.0%] +├─ f-5. E [total: 20.0%, self: 20.0%] +└─ f-6. F [total: 20.0%, self: 20.0%] +f-2. B [total: 20.0%, self: 20.0%] +f-3. C [total: 20.0%, self: 20.0%]" +`; + +exports[`call tree formatting top-down view different scoring strategies percentage-only strategy output 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Top-Down Call Tree: +f-1. A [total: 60.0%, self: 0.0%] +├─ f-4. D [total: 20.0%, self: 0.0%] +│ f-7. G [total: 20.0%, self: 20.0%] +├─ f-5. E [total: 20.0%, self: 20.0%] +└─ f-6. F [total: 20.0%, self: 20.0%] +f-2. B [total: 20.0%, self: 20.0%] +f-3. C [total: 20.0%, self: 20.0%]" +`; + +exports[`call tree formatting top-down view ordering and percentages correctly calculates percentages for nested nodes 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Top-Down Call Tree: +f-1. A [total: 100.0%, self: 0.0%] +├─ f-2. B [total: 60.0%, self: 10.0%] +│ ├─ f-5. E [total: 30.0%, self: 30.0%] +│ └─ f-6. F [total: 20.0%, self: 20.0%] +├─ f-3. C [total: 20.0%, self: 20.0%] +└─ f-4. D [total: 20.0%, self: 20.0%]" +`; + +exports[`call tree formatting top-down view ordering and percentages maintains correct ordering by sample count 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Top-Down Call Tree: +f-1. A [total: 100.0%, self: 0.0%] +├─ f-2. B [total: 50.0%, self: 50.0%] +├─ f-3. C [total: 30.0%, self: 30.0%] +└─ f-4. D [total: 20.0%, self: 20.0%]" +`; + +exports[`call tree formatting top-down view simple trees formats a branching tree 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Top-Down Call Tree: +f-1. A [total: 57.1%, self: 0.0%] +├─ f-4. D [total: 28.6%, self: 28.6%] +└─ f-5. E [total: 28.6%, self: 28.6%] +f-2. B [total: 28.6%, self: 28.6%] +f-3. C [total: 14.3%, self: 14.3%]" +`; + +exports[`call tree formatting top-down view simple trees formats a simple linear tree 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Top-Down Call Tree: +f-1. A [total: 100.0%, self: 0.0%] +└─ f-2. B [total: 100.0%, self: 0.0%] + f-3. C [total: 100.0%, self: 0.0%] + f-4. D [total: 100.0%, self: 100.0%]" +`; + +exports[`call tree formatting top-down view trees with truncation shows elided children at multiple levels 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Top-Down Call Tree: +f-1. A [total: 100.0%, self: 0.0%] +├─ f-2. B [total: 50.0%, self: 0.0%] +│ └─ ... (2 more children: combined 50.0%, max 25.0%) +├─ f-3. C [total: 25.0%, self: 0.0%] +│ └─ ... (2 more children: combined 25.0%, max 12.5%) +└─ f-4. D [total: 25.0%, self: 25.0%]" +`; + +exports[`call tree formatting top-down view trees with truncation shows elided children with correct percentages 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Top-Down Call Tree: +f-1. A [total: 100.0%, self: 0.0%] +├─ f-2. B [total: 30.0%, self: 30.0%] +└─ ... (6 more children: combined 70.0%, max 20.0%)" +`; + +exports[`call tree formatting top-down view trees with truncation shows truncation with wide trees (many siblings) 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1.00s] + +Thread: Test Thread + +Top-Down Call Tree: +f-1. A [total: 100.0%, self: 0.0%] +├─ f-2. B [total: 8.3%, self: 8.3%] +├─ f-3. I [total: 8.3%, self: 8.3%] +├─ f-4. J [total: 8.3%, self: 8.3%] +├─ f-5. K [total: 8.3%, self: 8.3%] +└─ ... (8 more children: combined 66.7%, max 8.3%)" +`; diff --git a/src/test/unit/profile-query-cli/call-tree-formatting.test.ts b/src/test/unit/profile-query-cli/call-tree-formatting.test.ts new file mode 100644 index 0000000000..bcc760e300 --- /dev/null +++ b/src/test/unit/profile-query-cli/call-tree-formatting.test.ts @@ -0,0 +1,617 @@ +/* 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/. */ + +import { FunctionMap } from '../../../profile-query/function-map'; +import { collectCallTree } from '../../../profile-query/formatters/call-tree'; +import type { + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + SessionContext, + WithContext, +} from '../../../profile-query/types'; +import { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; +import { storeWithProfile } from '../../fixtures/stores'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + formatThreadSamplesTopDownResult, + formatThreadSamplesBottomUpResult, +} from '../../../profile-query-cli/formatters'; +import type { CallTreeCollectionOptions } from '../../../profile-query/formatters/call-tree'; +import { + getCallTree, + computeCallTreeTimings, + computeCallNodeSelfAndSummary, +} from 'firefox-profiler/profile-logic/call-tree'; +import { getInvertedCallNodeInfo } from 'firefox-profiler/profile-logic/profile-data'; +import { + getCategories, + getDefaultCategory, +} from 'firefox-profiler/selectors/profile'; + +/** + * Helper to create a mock session context for testing. + */ +function createMockContext(): SessionContext { + return { + selectedThreadHandle: 't-0', + selectedThreads: [{ threadIndex: 0, name: 'Test Thread' }], + currentViewRange: null, + rootRange: { start: 0, end: 1000 }, + }; +} + +/** + * Helper to build a ThreadSamplesTopDownResult from a profile. + */ +function buildTopDownResult( + profileSamples: string, + options: CallTreeCollectionOptions = {} +): WithContext { + const { profile } = getProfileFromTextSamples(profileSamples); + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + const regularCallTree = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + options + ); + + return { + type: 'thread-samples-top-down', + threadHandle: 't-0', + friendlyThreadName: 'Test Thread', + regularCallTree, + context: createMockContext(), + }; +} + +/** + * Helper to build a ThreadSamplesBottomUpResult from a profile. + */ +function buildBottomUpResult( + profileSamples: string, + options: CallTreeCollectionOptions = {} +): WithContext { + const { profile } = getProfileFromTextSamples(profileSamples); + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + // Build inverted call tree (bottom-up view) + let collectedInvertedTree = null; + try { + const thread = threadSelectors.getFilteredThread(state); + const callNodeInfo = threadSelectors.getCallNodeInfo(state); + const categories = getCategories(state); + const defaultCategory = getDefaultCategory(state); + const weightType = threadSelectors.getWeightTypeForCallTree(state); + const samples = threadSelectors.getPreviewFilteredCtssSamples(state); + const sampleIndexToCallNodeIndex = + threadSelectors.getSampleIndexToNonInvertedCallNodeIndexForFilteredThread( + state + ); + + const callNodeSelfAndSummary = computeCallNodeSelfAndSummary( + samples, + sampleIndexToCallNodeIndex, + callNodeInfo.getCallNodeTable().length + ); + + const invertedCallNodeInfo = getInvertedCallNodeInfo( + callNodeInfo, + defaultCategory, + thread.funcTable.length + ); + + const invertedTimings = computeCallTreeTimings( + invertedCallNodeInfo, + callNodeSelfAndSummary + ); + + const invertedTree = getCallTree( + thread, + invertedCallNodeInfo, + categories, + samples, + invertedTimings, + weightType + ); + + collectedInvertedTree = collectCallTree( + invertedTree, + functionMap, + threadIndexes, + libs, + options + ); + } catch (e) { + // Failed to create inverted tree + console.error('Failed to create inverted call tree:', e); + } + + return { + type: 'thread-samples-bottom-up', + threadHandle: 't-0', + friendlyThreadName: 'Test Thread', + invertedCallTree: collectedInvertedTree, + context: createMockContext(), + }; +} + +describe('call tree formatting', function () { + describe('top-down view', function () { + describe('simple trees', function () { + it('formats a simple linear tree', function () { + const result = buildTopDownResult( + ` + A + B + C + D + `, + { maxNodes: 10 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + + it('formats a branching tree', function () { + const result = buildTopDownResult( + ` + A A A A B B C + D D E E + `, + { maxNodes: 10 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + }); + + describe('trees with truncation', function () { + it('shows elided children with correct percentages', function () { + const result = buildTopDownResult( + ` + A A A A A A A A A A + B B B C C D E F G H + `, + { maxNodes: 2 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + + it('shows elided children at multiple levels', function () { + const result = buildTopDownResult( + ` + A A A A A A A A + B B B B C C D D + E E F F G H + `, + { maxNodes: 4 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + + it('shows truncation with wide trees (many siblings)', function () { + const result = buildTopDownResult( + ` + A A A A A A A A A A A A + B C D E F G H I J K L M + `, + { maxNodes: 5, maxChildrenPerNode: 10 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + }); + + describe('complex nested trees', function () { + it('formats a deep nested path with branching', function () { + const result = buildTopDownResult( + ` + A A A A A A A A B B + C C C C C C D D + E E E E F F + G G H H + I I + `, + { maxNodes: 15 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + + it('formats a complex tree with mixed branching patterns', function () { + const result = buildTopDownResult( + ` + Main Main Main Main Main Main Main Main Main Main + Init Loop Loop Loop Loop Loop Loop Loop Loop Loop + Tick Tick Tick Tick Idle Idle Render Render Render + AI AI Phys Phys Layout Layout + Think Think + `, + { maxNodes: 15 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + }); + + describe('ordering and percentages', function () { + it('maintains correct ordering by sample count', function () { + const result = buildTopDownResult( + ` + A A A A A A A A A A + B B B B B C C C D D + `, + { maxNodes: 10 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + + // Verify ordering in the result structure + const aNode = result.regularCallTree.children[0]; + expect(aNode.children[0].name).toBe('B'); // 5 samples + expect(aNode.children[1].name).toBe('C'); // 3 samples + expect(aNode.children[2].name).toBe('D'); // 2 samples + }); + + it('correctly calculates percentages for nested nodes', function () { + const result = buildTopDownResult( + ` + A A A A A A A A A A + B B B B B B C C D D + E E E F F + `, + { maxNodes: 20 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + + // Verify percentages + const aNode = result.regularCallTree.children[0]; + expect(aNode.totalPercentage).toBeCloseTo(100, 0); + + const bNode = aNode.children[0]; + expect(bNode.totalPercentage).toBeCloseTo(60, 0); + + const eNode = bNode.children[0]; + expect(eNode.totalPercentage).toBeCloseTo(30, 0); + }); + }); + + describe('different scoring strategies', function () { + it('exponential-0.9 strategy output', function () { + const result = buildTopDownResult( + ` + A A A A A A B B C C + D D E E F F + G G + `, + { maxNodes: 8, scoringStrategy: 'exponential-0.9' } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + + it('percentage-only strategy output', function () { + const result = buildTopDownResult( + ` + A A A A A A B B C C + D D E E F F + G G + `, + { maxNodes: 8, scoringStrategy: 'percentage-only' } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + }); + }); + }); + + describe('bottom-up view', function () { + describe('simple trees', function () { + it('formats a simple linear tree inverted', function () { + const result = buildBottomUpResult( + ` + A + B + C + D + `, + { maxNodes: 10 } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + expect(formatted).toMatchSnapshot(); + }); + + it('formats a branching tree inverted', function () { + const result = buildBottomUpResult( + ` + A A A A B B C + D D E E + `, + { maxNodes: 10 } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + expect(formatted).toMatchSnapshot(); + }); + }); + + describe('trees with truncation', function () { + it('shows elided callers with correct percentages', function () { + const result = buildBottomUpResult( + ` + A A A A A A A A A A + B B B C C D E F G H + `, + { maxNodes: 5 } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + expect(formatted).toMatchSnapshot(); + }); + + it('shows elided callers at multiple levels', function () { + const result = buildBottomUpResult( + ` + A A A A A A A A + B B B B C C D D + E E F F G H + `, + { maxNodes: 8 } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + expect(formatted).toMatchSnapshot(); + }); + }); + + describe('complex nested trees', function () { + it('formats a deep call chain inverted', function () { + const result = buildBottomUpResult( + ` + Main Main Main Main Main Main Main Main + Loop Loop Loop Loop Loop Loop Loop Loop + Tick Tick Tick Tick Idle Idle Idle Idle + AI AI Phys Phys + Think Think + `, + { maxNodes: 15 } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + expect(formatted).toMatchSnapshot(); + }); + + it('shows which functions call a leaf function', function () { + const result = buildBottomUpResult( + ` + A A B B C C + D D D D D D + E E E E E E + `, + { maxNodes: 10 } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + expect(formatted).toMatchSnapshot(); + }); + }); + + describe('different scoring strategies', function () { + it('exponential-0.9 strategy for bottom-up', function () { + const result = buildBottomUpResult( + ` + A A A A A A B B C C + D D E E F F + G G + `, + { maxNodes: 8, scoringStrategy: 'exponential-0.9' } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + expect(formatted).toMatchSnapshot(); + }); + }); + + describe('elision bugs', function () { + it('elided children percentages should be relative to parent, not full profile', function () { + // Create a tree where B represents 50% of samples (5 out of 10). + // B has multiple callers (A1, A2, A3, A4, A5) that will be truncated. + // The elided caller percentages should be relative to B's total (50%), + // not relative to the full profile (100%). + const result = buildBottomUpResult( + ` + A1 A2 A3 A4 A5 C C C C C + B B B B B D D D D D + `, + { maxNodes: 3 } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + expect(formatted).toMatchSnapshot(); + + // Verify the bug: currently elided percentages are calculated relative to full profile + expect(result.invertedCallTree).toBeDefined(); + const bNode = result.invertedCallTree!.children.find( + (n) => n.name === 'B' + ); + expect(bNode).toBeDefined(); + + // B should have truncated children since we have limited nodes + // With the bug, the elided callers show as % of full profile (10 samples) + // After fix, they should show as % of B's total (5 samples = 50% of profile) + // The elided callers combined should be close to 100% of B's total, + // but with the bug they'll show as ~50% (or less depending on which callers were included) + + // For now, the snapshot will capture the buggy behavior + // After fix, we'll update snapshots and add more specific assertions + }); + + it('each parent node should have at most one elision marker', function () { + // Create a tree where a single parent has both depth limit and truncation + const result = buildTopDownResult( + ` + A A A A A A A A A A + B B B B B B B B B B + C C C C C C C C C C + D D D D D D D D D D + E E E E E E E E E E + F F F F F F F F F F + `, + { maxNodes: 3, maxDepth: 3 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + + // Verify that each parent has at most one elision marker + // Count consecutive elision markers (which would indicate duplicates for same parent) + const lines = formatted.split('\n'); + let consecutiveElisionCount = 0; + let maxConsecutiveElisions = 0; + + for (const line of lines) { + if (line.includes('└─ ...')) { + consecutiveElisionCount++; + maxConsecutiveElisions = Math.max( + maxConsecutiveElisions, + consecutiveElisionCount + ); + } else if (line.trim().length > 0) { + consecutiveElisionCount = 0; + } + } + + // Should never have more than 1 consecutive elision marker + expect(maxConsecutiveElisions).toBeLessThanOrEqual(1); + }); + + it('sibling nodes with elided children should each show their own elision marker', function () { + // Create a tree where two sibling nodes each have elided children + // This tests that elision markers are per-parent, not per-indentation-level + const result = buildTopDownResult( + ` + A A A A A A A A A A + B1 B1 B1 B1 B1 B2 B2 B2 B2 B2 + C1 C2 C3 C4 C5 D1 D2 D3 D4 D5 + `, + { maxNodes: 4 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + + // Count how many elision markers appear in the output + const lines = formatted.split('\n'); + const elisionMarkerCount = lines.filter((line) => + line.includes('└─ ...') + ).length; + + // We expect at least 2 elision markers (one for each sibling B1 and B2) + // Both have many children but limited maxNodes, so both should have elisions + expect(elisionMarkerCount).toBeGreaterThanOrEqual(2); + }); + + it('node whose children were never expanded must still show elision marker', function () { + // Reproduce bug where CallWindowProcW has 55.8% total, 0% self, but no elision marker + // This happens when a node is included but hits the budget limit before its children are expanded + const result = buildTopDownResult( + ` + Root Root Root Root Root Root Root Root Root Root + A A A A A A B B C D + A1 A2 A3 A4 A5 A6 B1 B2 + `, + { maxNodes: 4, maxChildrenPerNode: 2 } // Very tight: Root, A, B, C (A never expanded) + ); + + const formatted = formatThreadSamplesTopDownResult(result); + expect(formatted).toMatchSnapshot(); + + // Parse the tree and verify invariant: every node with total > self must show where the time went + const lines = formatted.split('\n'); + const violations: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Match node lines like "├─ f-2. A [total: 50.0%, self: 0.0%]" or "f-2. A [total: 50.0%, self: 0.0%]" + const match = line.match( + /[├└]?─?\s*f-\d+\.\s+(.+?)\s+\[total:\s+([\d.]+)%,\s+self:\s+([\d.]+)%\]/ + ); + if (match) { + const nodeName = match[1]; + const total = parseFloat(match[2]); + const self = parseFloat(match[3]); + + // If total > self, this node has children that account for the difference + if (total > self + 0.01) { + // Check the next line - it must be either a child node or an elision marker + const nextLine = i + 1 < lines.length ? lines[i + 1] : ''; + + // A child line either: + // 1. Starts with more whitespace than current line (deeper nesting) + // 2. Contains tree symbols │, ├─, or └─ + // 3. Contains an elision marker └─ ... + + const currentLeadingSpaces = + line.match(/^(\s*)/)?.[1].length || 0; + const nextLeadingSpaces = + nextLine.match(/^(\s*)/)?.[1].length || 0; + + const hasTreeSymbols = + nextLine.includes('│') || + nextLine.includes('├─') || + nextLine.includes('└─'); + + const isChild = + nextLine.trim().length > 0 && + (nextLeadingSpaces > currentLeadingSpaces || hasTreeSymbols); + + if (!isChild) { + violations.push( + `Node "${nodeName}" has total=${total}%, self=${self}% but no child/elision marker:\n Line ${i + 1}: ${line}\n Next: ${nextLine}` + ); + } + } + } + } + + // Report all violations + if (violations.length > 0) { + throw new Error( + `Found ${violations.length} node(s) missing elision markers:\n\n` + + violations.join('\n\n') + ); + } + }); + }); + }); +}); diff --git a/src/test/unit/profile-query-cli/client.test.ts b/src/test/unit/profile-query-cli/client.test.ts new file mode 100644 index 0000000000..cbc5831b16 --- /dev/null +++ b/src/test/unit/profile-query-cli/client.test.ts @@ -0,0 +1,28 @@ +/* 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/. */ + +/** + * Unit tests for profile-query-cli client. + * + * NOTE: This file intentionally contains no tests. + * + * The client.ts module handles cross-process communication via Unix sockets, + * which requires spawning separate daemon processes. This is too complex to + * manage reliably in Jest unit tests. + * + * Instead, client functionality is tested through integration tests in bash + * scripts: + * - bin/pq-test: Basic daemon lifecycle and client-server communication + * - bin/pq-test-multi: Concurrent client sessions + * + * Do not add unit tests here. If you need to test pure utility functions from + * client.ts, extract them to a separate module and test that module instead. + */ + +describe('profile-query-cli client', function () { + it('has no unit tests (see comment above)', function () { + // This test exists only to prevent Jest from complaining about an empty suite + expect(true).toBe(true); + }); +}); diff --git a/src/test/unit/profile-query-cli/daemon.test.ts b/src/test/unit/profile-query-cli/daemon.test.ts new file mode 100644 index 0000000000..5f42834c75 --- /dev/null +++ b/src/test/unit/profile-query-cli/daemon.test.ts @@ -0,0 +1,28 @@ +/* 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/. */ + +/** + * Unit tests for profile-query-cli daemon. + * + * NOTE: This file intentionally contains no tests. + * + * The daemon.ts module handles cross-process communication via Unix sockets, + * spawns background processes, and manages long-lived server state. This is + * too complex to manage reliably in Jest unit tests. + * + * Instead, daemon functionality is tested through integration tests in bash + * scripts: + * - bin/pq-test: Basic daemon lifecycle (start, connect, stop) + * - bin/pq-test-multi: Multiple concurrent daemon sessions + * + * Do not add unit tests here. If you need to test pure utility functions from + * daemon.ts, extract them to a separate module and test that module instead. + */ + +describe('profile-query-cli daemon', function () { + it('has no unit tests (see comment above)', function () { + // This test exists only to prevent Jest from complaining about an empty suite + expect(true).toBe(true); + }); +}); diff --git a/src/test/unit/profile-query-cli/session.test.ts b/src/test/unit/profile-query-cli/session.test.ts new file mode 100644 index 0000000000..c6ad3c7e79 --- /dev/null +++ b/src/test/unit/profile-query-cli/session.test.ts @@ -0,0 +1,355 @@ +/* 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/. */ + +/** + * Unit tests for profile-query CLI session management. + * + * These tests cover only the session.ts utility functions. + * Integration tests that spawn daemons and test IPC are in bash scripts: + * - bin/pq-test: Basic daemon lifecycle + * - bin/pq-test-multi: Concurrent sessions + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + ensureSessionDir, + generateSessionId, + getSocketPath, + getLogPath, + getMetadataPath, + saveSessionMetadata, + loadSessionMetadata, + setCurrentSession, + getCurrentSessionId, + getCurrentSocketPath, + isProcessRunning, + cleanupSession, + validateSession, + listSessions, +} from 'firefox-profiler/profile-query-cli/session'; +import type { SessionMetadata } from 'firefox-profiler/profile-query-cli/protocol'; + +const TEST_BUILD_HASH = 'test-build-hash'; + +describe('profile-query-cli session management', function () { + let testSessionDir: string; + + beforeEach(function () { + // Create a unique temp directory for each test + testSessionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pq-test-')); + }); + + afterEach(function () { + // Clean up test directory + if (fs.existsSync(testSessionDir)) { + fs.rmSync(testSessionDir, { recursive: true, force: true }); + } + }); + + describe('ensureSessionDir', function () { + it('creates session directory if it does not exist', function () { + const newDir = path.join(testSessionDir, 'subdir'); + expect(fs.existsSync(newDir)).toBe(false); + + ensureSessionDir(newDir); + + expect(fs.existsSync(newDir)).toBe(true); + expect(fs.statSync(newDir).isDirectory()).toBe(true); + }); + + it('does not fail if directory already exists', function () { + ensureSessionDir(testSessionDir); + + expect(() => ensureSessionDir(testSessionDir)).not.toThrow(); + expect(fs.existsSync(testSessionDir)).toBe(true); + }); + }); + + describe('generateSessionId', function () { + it('returns a non-empty string', function () { + const sessionId = generateSessionId(); + expect(typeof sessionId).toBe('string'); + expect(sessionId.length).toBeGreaterThan(0); + }); + + it('returns different IDs on successive calls', function () { + const id1 = generateSessionId(); + const id2 = generateSessionId(); + expect(id1).not.toBe(id2); + }); + }); + + describe('path generation', function () { + it('getSocketPath returns correct path', function () { + const sessionId = 'test123'; + const socketPath = getSocketPath(testSessionDir, sessionId); + expect(socketPath).toBe(path.join(testSessionDir, 'test123.sock')); + }); + + it('getLogPath returns correct path', function () { + const sessionId = 'test123'; + const logPath = getLogPath(testSessionDir, sessionId); + expect(logPath).toBe(path.join(testSessionDir, 'test123.log')); + }); + + it('getMetadataPath returns correct path', function () { + const sessionId = 'test123'; + const metadataPath = getMetadataPath(testSessionDir, sessionId); + expect(metadataPath).toBe(path.join(testSessionDir, 'test123.json')); + }); + }); + + describe('metadata serialization', function () { + it('saves and loads metadata correctly', function () { + const metadata: SessionMetadata = { + id: 'test123', + socketPath: getSocketPath(testSessionDir, 'test123'), + logPath: getLogPath(testSessionDir, 'test123'), + pid: 12345, + profilePath: '/path/to/profile.json', + createdAt: '2025-10-31T10:00:00.000Z', + buildHash: TEST_BUILD_HASH, + }; + + saveSessionMetadata(testSessionDir, metadata); + + const loaded = loadSessionMetadata(testSessionDir, 'test123'); + expect(loaded).toEqual(metadata); + }); + + it('returns null for non-existent session', function () { + const loaded = loadSessionMetadata(testSessionDir, 'nonexistent'); + expect(loaded).toBeNull(); + }); + + it('returns null for malformed JSON', function () { + const metadataPath = getMetadataPath(testSessionDir, 'bad'); + fs.writeFileSync(metadataPath, 'not valid JSON {'); + + const loaded = loadSessionMetadata(testSessionDir, 'bad'); + expect(loaded).toBeNull(); + }); + }); + + describe('current session tracking', function () { + it('sets and gets current session via symlink', function () { + const sessionId = 'test123'; + const socketPath = getSocketPath(testSessionDir, sessionId); + fs.writeFileSync(socketPath, ''); + + setCurrentSession(testSessionDir, sessionId); + + const currentId = getCurrentSessionId(testSessionDir); + expect(currentId).toBe(sessionId); + }); + + it('returns null when no current session exists', function () { + const currentId = getCurrentSessionId(testSessionDir); + expect(currentId).toBeNull(); + }); + + it('replaces existing current session symlink', function () { + // Create first session + const socket1 = getSocketPath(testSessionDir, 'session1'); + fs.writeFileSync(socket1, ''); + setCurrentSession(testSessionDir, 'session1'); + expect(getCurrentSessionId(testSessionDir)).toBe('session1'); + + // Create second session + const socket2 = getSocketPath(testSessionDir, 'session2'); + fs.writeFileSync(socket2, ''); + setCurrentSession(testSessionDir, 'session2'); + expect(getCurrentSessionId(testSessionDir)).toBe('session2'); + }); + + it('getCurrentSocketPath resolves to correct path', function () { + const sessionId = 'test123'; + const socketPath = getSocketPath(testSessionDir, sessionId); + fs.writeFileSync(socketPath, ''); + + setCurrentSession(testSessionDir, sessionId); + + const currentPath = getCurrentSocketPath(testSessionDir); + expect(currentPath).toBe(socketPath); + }); + }); + + describe('isProcessRunning', function () { + it('returns true for current process', function () { + expect(isProcessRunning(process.pid)).toBe(true); + }); + + it('returns false for non-existent PID', function () { + expect(isProcessRunning(999999)).toBe(false); + }); + }); + + describe('cleanupSession', function () { + it('removes socket and metadata files', function () { + const sessionId = 'test123'; + const socketPath = getSocketPath(testSessionDir, sessionId); + const metadataPath = getMetadataPath(testSessionDir, sessionId); + + fs.writeFileSync(socketPath, ''); + fs.writeFileSync(metadataPath, '{}'); + + cleanupSession(testSessionDir, sessionId); + + expect(fs.existsSync(socketPath)).toBe(false); + expect(fs.existsSync(metadataPath)).toBe(false); + }); + + it('preserves log file', function () { + const sessionId = 'test123'; + const logPath = getLogPath(testSessionDir, sessionId); + fs.writeFileSync(logPath, 'log data'); + + cleanupSession(testSessionDir, sessionId); + + expect(fs.existsSync(logPath)).toBe(true); + }); + + it('removes current session symlink if it points to this session', function () { + const sessionId = 'test123'; + const socketPath = getSocketPath(testSessionDir, sessionId); + fs.writeFileSync(socketPath, ''); + setCurrentSession(testSessionDir, sessionId); + + cleanupSession(testSessionDir, sessionId); + + expect(getCurrentSessionId(testSessionDir)).toBeNull(); + }); + + it('does not remove current session symlink if it points to different session', function () { + // Set current session to session1 + const socket1 = getSocketPath(testSessionDir, 'session1'); + fs.writeFileSync(socket1, ''); + setCurrentSession(testSessionDir, 'session1'); + + // Clean up session2 + cleanupSession(testSessionDir, 'session2'); + + // Current session should still be session1 + expect(getCurrentSessionId(testSessionDir)).toBe('session1'); + }); + }); + + describe('validateSession', function () { + it('returns false for non-existent session', function () { + expect(validateSession(testSessionDir, 'nonexistent')).toBe(null); + }); + + it('returns false for session with dead PID', function () { + const sessionId = 'test123'; + const metadata: SessionMetadata = { + id: sessionId, + socketPath: getSocketPath(testSessionDir, sessionId), + logPath: getLogPath(testSessionDir, sessionId), + pid: 999999, // Non-existent PID + profilePath: '/path/to/profile.json', + createdAt: new Date().toISOString(), + buildHash: TEST_BUILD_HASH, + }; + + saveSessionMetadata(testSessionDir, metadata); + fs.writeFileSync(metadata.socketPath, ''); + + expect(validateSession(testSessionDir, sessionId)).toBe(null); + }); + + it('returns false for session with missing socket', function () { + const sessionId = 'test123'; + const metadata: SessionMetadata = { + id: sessionId, + socketPath: getSocketPath(testSessionDir, sessionId), + logPath: getLogPath(testSessionDir, sessionId), + pid: process.pid, // Use current process PID (guaranteed to exist) + profilePath: '/path/to/profile.json', + createdAt: new Date().toISOString(), + buildHash: TEST_BUILD_HASH, + }; + + saveSessionMetadata(testSessionDir, metadata); + // Intentionally don't create socket file + + expect(validateSession(testSessionDir, sessionId)).toBe(null); + }); + + it('returns true for valid session', function () { + const sessionId = 'test123'; + const metadata: SessionMetadata = { + id: sessionId, + socketPath: getSocketPath(testSessionDir, sessionId), + logPath: getLogPath(testSessionDir, sessionId), + pid: process.pid, // Use current process PID + profilePath: '/path/to/profile.json', + createdAt: new Date().toISOString(), + buildHash: TEST_BUILD_HASH, + }; + + saveSessionMetadata(testSessionDir, metadata); + fs.writeFileSync(metadata.socketPath, ''); + + expect(validateSession(testSessionDir, sessionId)).not.toBe(null); + }); + }); + + describe('listSessions', function () { + it('returns empty array when no sessions exist', function () { + const sessions = listSessions(testSessionDir); + expect(sessions).toEqual([]); + }); + + it('lists all session IDs', function () { + // Create multiple sessions + saveSessionMetadata(testSessionDir, { + id: 'session1', + socketPath: getSocketPath(testSessionDir, 'session1'), + logPath: getLogPath(testSessionDir, 'session1'), + pid: 1, + profilePath: '/test1.json', + createdAt: new Date().toISOString(), + buildHash: TEST_BUILD_HASH, + }); + + saveSessionMetadata(testSessionDir, { + id: 'session2', + socketPath: getSocketPath(testSessionDir, 'session2'), + logPath: getLogPath(testSessionDir, 'session2'), + pid: 2, + profilePath: '/test2.json', + createdAt: new Date().toISOString(), + buildHash: TEST_BUILD_HASH, + }); + + const sessions = listSessions(testSessionDir); + expect(sessions).toContain('session1'); + expect(sessions).toContain('session2'); + expect(sessions.length).toBe(2); + }); + + it('ignores non-JSON files', function () { + // Create session metadata + saveSessionMetadata(testSessionDir, { + id: 'session1', + socketPath: getSocketPath(testSessionDir, 'session1'), + logPath: getLogPath(testSessionDir, 'session1'), + pid: 1, + profilePath: '/test.json', + createdAt: new Date().toISOString(), + buildHash: TEST_BUILD_HASH, + }); + + // Create non-JSON files + fs.writeFileSync(path.join(testSessionDir, 'session1.sock'), ''); + fs.writeFileSync(path.join(testSessionDir, 'session1.log'), ''); + fs.writeFileSync(path.join(testSessionDir, 'random.txt'), ''); + + const sessions = listSessions(testSessionDir); + expect(sessions).toEqual(['session1']); + }); + }); +}); diff --git a/src/test/unit/profile-query-marker-utils.test.ts b/src/test/unit/profile-query-marker-utils.test.ts new file mode 100644 index 0000000000..0752445966 --- /dev/null +++ b/src/test/unit/profile-query-marker-utils.test.ts @@ -0,0 +1,208 @@ +/* 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/. */ + +import { + computeDurationStats, + computeRateStats, + formatDuration, +} from '../../profile-query/formatters/marker-info'; + +import type { Marker } from 'firefox-profiler/types'; + +describe('marker-info utility functions', function () { + describe('formatDuration', function () { + it('formats microseconds correctly', function () { + expect(formatDuration(0.001)).toBe('1.00μs'); + expect(formatDuration(0.5)).toBe('500.00μs'); + expect(formatDuration(0.999)).toBe('999.00μs'); + }); + + it('formats milliseconds correctly', function () { + expect(formatDuration(1)).toBe('1.00ms'); + expect(formatDuration(10.5)).toBe('10.50ms'); + expect(formatDuration(999)).toBe('999.00ms'); + }); + + it('formats seconds correctly', function () { + expect(formatDuration(1000)).toBe('1.00s'); + expect(formatDuration(5500)).toBe('5.50s'); + expect(formatDuration(60000)).toBe('60.00s'); + }); + }); + + describe('computeDurationStats', function () { + function makeMarker(start: number, end: number | null): Marker { + return { + start, + end, + name: 'TestMarker', + category: 0, + data: null, + threadId: null, + }; + } + + it('returns undefined for empty marker list', function () { + expect(computeDurationStats([])).toBe(undefined); + }); + + it('returns undefined for instant markers only', function () { + const markers = [ + makeMarker(0, null), + makeMarker(1, null), + makeMarker(2, null), + ]; + expect(computeDurationStats(markers)).toBe(undefined); + }); + + it('computes stats for interval markers', function () { + const markers = [ + makeMarker(0, 1), // 1ms + makeMarker(1, 3), // 2ms + makeMarker(3, 6), // 3ms + makeMarker(6, 10), // 4ms + makeMarker(10, 15), // 5ms + ]; + + const stats = computeDurationStats(markers); + expect(stats).toBeDefined(); + expect(stats!.min).toBe(1); + expect(stats!.max).toBe(5); + expect(stats!.avg).toBe(3); + expect(stats!.median).toBe(3); + // For 5 items: p95 = floor(5 * 0.95) = floor(4.75) = 4th index (0-based) = 5 + expect(stats!.p95).toBe(5); + // For 5 items: p99 = floor(5 * 0.99) = floor(4.95) = 4th index (0-based) = 5 + expect(stats!.p99).toBe(5); + }); + + it('handles mixed instant and interval markers', function () { + const markers = [ + makeMarker(0, null), // instant + makeMarker(1, 2), // 1ms + makeMarker(2, null), // instant + makeMarker(3, 5), // 2ms + ]; + + const stats = computeDurationStats(markers); + expect(stats).toBeDefined(); + expect(stats!.min).toBe(1); + expect(stats!.max).toBe(2); + expect(stats!.avg).toBe(1.5); + }); + + it('computes correct percentiles for larger datasets', function () { + // Create 100 markers with durations 1-100ms + const markers = Array.from({ length: 100 }, (_, i) => + makeMarker(i * 10, i * 10 + i + 1) + ); + + const stats = computeDurationStats(markers); + expect(stats).toBeDefined(); + expect(stats!.min).toBe(1); + expect(stats!.max).toBe(100); + // Median: floor(100/2) = 50th index (0-based) = value 51 + expect(stats!.median).toBe(51); + // p95 = floor(100 * 0.95) = 95th index (0-based) = value 96 + expect(stats!.p95).toBe(96); + // p99 = floor(100 * 0.99) = 99th index (0-based) = value 100 + expect(stats!.p99).toBe(100); + }); + }); + + describe('computeRateStats', function () { + function makeMarker(start: number, end: number | null): Marker { + return { + start, + end, + name: 'TestMarker', + category: 0, + data: null, + threadId: null, + }; + } + + it('handles empty marker list', function () { + const stats = computeRateStats([]); + expect(stats.markersPerSecond).toBe(0); + expect(stats.minGap).toBe(0); + expect(stats.avgGap).toBe(0); + expect(stats.maxGap).toBe(0); + }); + + it('handles single marker', function () { + const stats = computeRateStats([makeMarker(5, 10)]); + expect(stats.markersPerSecond).toBe(0); + expect(stats.minGap).toBe(0); + expect(stats.avgGap).toBe(0); + expect(stats.maxGap).toBe(0); + }); + + it('computes rate for evenly spaced markers', function () { + // Markers at 0, 100, 200, 300, 400 (100ms gaps) + const markers = [ + makeMarker(0, null), + makeMarker(100, null), + makeMarker(200, null), + makeMarker(300, null), + makeMarker(400, null), + ]; + + const stats = computeRateStats(markers); + // Time range: 400 - 0 = 400ms = 0.4s + // 5 markers in 0.4s = 12.5 markers/sec + expect(stats.markersPerSecond).toBeCloseTo(12.5, 5); + expect(stats.minGap).toBe(100); + expect(stats.avgGap).toBe(100); + expect(stats.maxGap).toBe(100); + }); + + it('computes rate for unevenly spaced markers', function () { + const markers = [ + makeMarker(0, null), + makeMarker(10, null), // 10ms gap + makeMarker(15, null), // 5ms gap + makeMarker(100, null), // 85ms gap + ]; + + const stats = computeRateStats(markers); + // Time range: 100 - 0 = 100ms = 0.1s + // 4 markers in 0.1s = 40 markers/sec + expect(stats.markersPerSecond).toBeCloseTo(40, 5); + expect(stats.minGap).toBe(5); + expect(stats.avgGap).toBeCloseTo((10 + 5 + 85) / 3, 5); + expect(stats.maxGap).toBe(85); + }); + + it('sorts markers by start time before computing gaps', function () { + // Provide markers out of order + const markers = [ + makeMarker(100, null), + makeMarker(0, null), + makeMarker(50, null), + ]; + + const stats = computeRateStats(markers); + // After sorting: 0, 50, 100 + // Gaps: 50, 50 + expect(stats.minGap).toBe(50); + expect(stats.avgGap).toBe(50); + expect(stats.maxGap).toBe(50); + }); + + it('handles markers at same timestamp', function () { + const markers = [ + makeMarker(100, null), + makeMarker(100, null), // Same timestamp + makeMarker(200, null), + ]; + + const stats = computeRateStats(markers); + // Gaps: 0, 100 + expect(stats.minGap).toBe(0); + expect(stats.avgGap).toBe(50); + expect(stats.maxGap).toBe(100); + }); + }); +}); diff --git a/src/test/unit/profile-query/call-tree.test.ts b/src/test/unit/profile-query/call-tree.test.ts new file mode 100644 index 0000000000..e62d5187e3 --- /dev/null +++ b/src/test/unit/profile-query/call-tree.test.ts @@ -0,0 +1,589 @@ +/* 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/. */ + +import { FunctionMap } from '../../../profile-query/function-map'; +import { collectCallTree } from '../../../profile-query/formatters/call-tree'; +import type { CallTreeNode } from '../../../profile-query/types'; +import { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; +import { storeWithProfile } from '../../fixtures/stores'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; + +describe('call-tree collection', function () { + describe('simple linear tree', function () { + it('respects node budget', function () { + const { profile } = getProfileFromTextSamples(` + A + B + C + D + E + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + // Collect with budget of 3 nodes + const result = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + { + maxNodes: 3, + } + ); + + // Count nodes (excluding virtual root) + const nodeCount = countNodes(result) - 1; + expect(nodeCount).toBeLessThanOrEqual(3); + }); + + it('includes high-score nodes even when deep', function () { + const { profile } = getProfileFromTextSamples(` + A A A + B B B + C C C + D D D + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + // With small budget, should still include D (100% at depth 3) + const result = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + { + maxNodes: 4, + } + ); + + // Should include: A, B, C, D + const nodeNames = collectNodeNames(result); + expect(nodeNames).toContain('A'); + expect(nodeNames).toContain('B'); + expect(nodeNames).toContain('C'); + expect(nodeNames).toContain('D'); + }); + }); + + describe('branching tree', function () { + it('explores hot paths first', function () { + const { profile } = getProfileFromTextSamples(` + A A A A + B B C C + D D + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + // With budget of 4: should get A, B (50%), D (50%), C (50%) + const result = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + { + maxNodes: 4, + } + ); + + const nodeNames = collectNodeNames(result); + expect(nodeNames).toContain('A'); + expect(nodeNames).toContain('B'); // Hot child (50%) + expect(nodeNames).toContain('C'); // Also 50% + // D might or might not be included depending on score ordering + }); + + it('computes elided children stats', function () { + const { profile } = getProfileFromTextSamples(` + A A A A A + B B C D E + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + // With budget of 2: A and B, should show C/D/E as elided + const result = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + { + maxNodes: 2, + } + ); + + const aNode = result.children[0]; + expect(aNode.name).toBe('A'); + expect(aNode.childrenTruncated).toBeDefined(); + expect(aNode.childrenTruncated?.count).toBeGreaterThan(0); + }); + }); + + describe('scoring strategies', function () { + it('exponential-0.9 balances depth and breadth', function () { + const { profile } = getProfileFromTextSamples(` + A A B + C C + D D + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + const result = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + { + maxNodes: 4, + scoringStrategy: 'exponential-0.9', + } + ); + + const nodeNames = collectNodeNames(result); + expect(nodeNames).toContain('A'); // 66% at depth 0 + expect(nodeNames).toContain('B'); // 33% at depth 0 + }); + + it('percentage-only ignores depth', function () { + const { profile } = getProfileFromTextSamples(` + A + B + C + D + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + const result = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + { + maxNodes: 4, + scoringStrategy: 'percentage-only', + } + ); + + // All nodes should have same priority (100%), so all included + const nodeCount = countNodes(result) - 1; + expect(nodeCount).toBe(4); + }); + }); + + describe('complex branching trees', function () { + it('handles multiple levels of branching correctly', function () { + const { profile } = getProfileFromTextSamples(` + A A A A A A B B C + D D E E F F G G + H H I I + J J + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + const result = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + { + maxNodes: 10, + scoringStrategy: 'exponential-0.9', + } + ); + + const nodeNames = collectNodeNames(result); + // Should include high-percentage nodes + expect(nodeNames).toContain('A'); // 66% at depth 0 + expect(nodeNames).toContain('B'); // 22% at depth 0 + expect(nodeNames).toContain('D'); // 22% under A + expect(nodeNames).toContain('E'); // 22% under A + + const nodeCount = countNodes(result) - 1; + expect(nodeCount).toBeLessThanOrEqual(10); + }); + + it('correctly computes elided children percentages', function () { + const { profile } = getProfileFromTextSamples(` + A A A A A A A A A A + B B C C D D E F G H + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + // Small budget to force truncation + const result = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + { + maxNodes: 3, + } + ); + + const aNode = result.children[0]; + expect(aNode.name).toBe('A'); + expect(aNode.childrenTruncated).toBeDefined(); + + // Verify the count and percentages are correct + const truncInfo = aNode.childrenTruncated!; + expect(truncInfo.count).toBeGreaterThan(0); + expect(truncInfo.combinedPercentage).toBeGreaterThan(0); + expect(truncInfo.maxPercentage).toBeGreaterThan(0); + // Max percentage should be <= combined percentage + expect(truncInfo.maxPercentage).toBeLessThanOrEqual( + truncInfo.combinedPercentage + ); + }); + + it('handles wide trees with many children', function () { + // Create a wide tree: A has 15 children + const samples = ` + A A A A A A A A A A A A A A A A + B C D E F G H I J K L M N O P Q + `; + + const { profile } = getProfileFromTextSamples(samples); + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + // First verify that A has many children + const roots = callTree.getRoots(); + expect(roots.length).toBe(1); + const aCallNode = roots[0]; + const aChildren = callTree.getChildren(aCallNode); + expect(aChildren.length).toBe(16); // B through Q + + const result = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + { + maxNodes: 5, // Small budget to ensure truncation + maxChildrenPerNode: 10, + } + ); + + const aNode = result.children[0]; + expect(aNode.name).toBe('A'); + + // A has 16 children, but we can only expand 10 (maxChildrenPerNode) + // With budget of 5 total nodes (A + 4 children), we should have truncation + // Either from the 10 expanded children (6 not included) + 6 not expanded = 12 total + // Or if fewer than 4 children included, even more truncated + expect(aNode.childrenTruncated).toBeDefined(); + expect(aNode.childrenTruncated!.count).toBeGreaterThan(0); + }); + + it('preserves correct ordering by sample count', function () { + const { profile } = getProfileFromTextSamples(` + A A A A A A A A + B B B C C D + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + const result = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + { + maxNodes: 10, + } + ); + + const aNode = result.children[0]; + expect(aNode.name).toBe('A'); + + // Children should be ordered B (3 samples), C (2 samples), D (1 sample) + expect(aNode.children.length).toBeGreaterThanOrEqual(2); + expect(aNode.children[0].name).toBe('B'); // Highest sample count + expect(aNode.children[1].name).toBe('C'); + }); + }); + + describe('deep nested structures', function () { + it('includes deep hot paths over shallow cold paths', function () { + const { profile } = getProfileFromTextSamples(` + A A A A A A A A B C + D D D D D D D D + E E E E E E E E + F F F F F F F F + G G G G G G G G + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + const result = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + { + maxNodes: 8, + scoringStrategy: 'exponential-0.9', + } + ); + + const nodeNames = collectNodeNames(result); + // Should include deep path A->D->E->F->G even though it's deep + // because it's 80% of all samples + expect(nodeNames).toContain('A'); + expect(nodeNames).toContain('D'); + expect(nodeNames).toContain('E'); + expect(nodeNames).toContain('F'); + expect(nodeNames).toContain('G'); + }); + + it('respects maxDepth parameter', function () { + // Create deeply nested tree + const samples = Array(50) + .fill(null) + .map((_, i) => `Func${i}`) + .join('\n'); + + const { profile } = getProfileFromTextSamples(samples); + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + const result = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + { + maxNodes: 100, + maxDepth: 20, + } + ); + + const maxDepth = findMaxDepth(result); + expect(maxDepth).toBeLessThanOrEqual(20); + }); + }); + + describe('elided children statistics', function () { + it('correctly sums elided children samples', function () { + const { profile } = getProfileFromTextSamples(` + A A A A A A A A A A + B B B C C D E F G H + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + // Budget that includes A and B, but not the other children + const result = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + { + maxNodes: 2, + } + ); + + const aNode = result.children[0]; + expect(aNode.name).toBe('A'); + expect(aNode.children.length).toBe(1); + expect(aNode.children[0].name).toBe('B'); + + // Should have truncated info for C, D, E, F, G, H + expect(aNode.childrenTruncated).toBeDefined(); + expect(aNode.childrenTruncated!.count).toBe(6); + + // Combined samples should be 7 (C:2, D:1, E:1, F:1, G:1, H:1) + expect(aNode.childrenTruncated!.combinedSamples).toBe(7); + // Combined percentage should be 70% of total 10 samples (not relative to A) + expect(aNode.childrenTruncated!.combinedPercentage).toBeCloseTo(70, 0); + + // Max samples should be 2 (from C) + expect(aNode.childrenTruncated!.maxSamples).toBe(2); + // Max percentage should be 20% of total 10 samples (not relative to A) + expect(aNode.childrenTruncated!.maxPercentage).toBeCloseTo(20, 0); + }); + + it('correctly identifies depth where children were truncated', function () { + const { profile } = getProfileFromTextSamples(` + A A A A + B B B B + C D E F + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + const result = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + { + maxNodes: 2, + } + ); + + const aNode = result.children[0]; + const bNode = aNode.children[0]; + expect(bNode.name).toBe('B'); + + // B's children were truncated at depth 2 + expect(bNode.childrenTruncated).toBeDefined(); + expect(bNode.childrenTruncated!.depth).toBe(2); + }); + }); + + describe('depth limit', function () { + it('stops expanding beyond maxDepth', function () { + // Very deep tree + const samples = Array(100) + .fill(null) + .map((_, i) => `Func${i}`) + .join('\n'); + + const { profile } = getProfileFromTextSamples(samples); + + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const callTree = threadSelectors.getCallTree(state); + const functionMap = new FunctionMap(); + const threadIndexes = new Set([0]); + const libs = profile.libs; + + const result = collectCallTree( + callTree, + functionMap, + threadIndexes, + libs, + { + maxNodes: 1000, // High budget + maxDepth: 10, // But limited depth + } + ); + + const maxDepthFound = findMaxDepth(result); + expect(maxDepthFound).toBeLessThanOrEqual(10); + }); + }); +}); + +/** + * Count total nodes in tree (including root). + */ +function countNodes(node: CallTreeNode): number { + let count = 1; + for (const child of node.children) { + count += countNodes(child); + } + return count; +} + +/** + * Collect all node names in tree. + */ +function collectNodeNames(node: CallTreeNode): string[] { + const names = [node.name]; + for (const child of node.children) { + names.push(...collectNodeNames(child)); + } + return names; +} + +/** + * Find maximum depth in tree. + */ +function findMaxDepth(node: CallTreeNode): number { + if (node.children.length === 0) { + return node.originalDepth; + } + return Math.max(...node.children.map((child) => findMaxDepth(child))); +} diff --git a/src/test/unit/profile-query/function-list.test.ts b/src/test/unit/profile-query/function-list.test.ts new file mode 100644 index 0000000000..9f3974ffe5 --- /dev/null +++ b/src/test/unit/profile-query/function-list.test.ts @@ -0,0 +1,608 @@ +/* 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/. */ + +import { FunctionMap } from 'firefox-profiler/profile-query/function-map'; +import { + extractFunctionData, + sortByTotal, + sortBySelf, + formatFunctionList, + createTopFunctionLists, + truncateFunctionName, + type FunctionData, +} from '../../../profile-query/function-list'; +import { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; +import type { Lib } from 'firefox-profiler/types'; + +function createMockTree(functions: FunctionData[]) { + return { + getRoots: () => functions.map((_, i) => i), + getNodeData: (index: number) => functions[index], + }; +} + +describe('function-list', function () { + describe('extractFunctionData', function () { + it('extracts function data from a tree', function () { + const { profile, derivedThreads } = getProfileFromTextSamples(` + foo + bar + `); + const [thread] = derivedThreads; + const libs: Lib[] = profile.libs; + + const functions: FunctionData[] = [ + { + funcName: 'foo', + funcIndex: 0, + total: 100, + self: 50, + totalRelative: 0.5, + selfRelative: 0.25, + }, + { + funcName: 'bar', + funcIndex: 1, + total: 80, + self: 60, + totalRelative: 0.4, + selfRelative: 0.3, + }, + ]; + + const tree = createMockTree(functions); + const result = extractFunctionData(tree, thread, libs); + + expect(result).toEqual(functions); + }); + }); + + describe('sortByTotal', function () { + it('sorts functions by total time descending', function () { + const functions: FunctionData[] = [ + { + funcName: 'foo', + funcIndex: 0, + total: 50, + self: 30, + totalRelative: 0.25, + selfRelative: 0.15, + }, + { + funcName: 'bar', + funcIndex: 0, + total: 100, + self: 20, + totalRelative: 0.5, + selfRelative: 0.1, + }, + { + funcName: 'baz', + funcIndex: 0, + total: 75, + self: 40, + totalRelative: 0.375, + selfRelative: 0.2, + }, + ]; + + const sorted = sortByTotal(functions); + + expect(sorted.map((f) => f.funcName)).toEqual(['bar', 'baz', 'foo']); + expect(sorted.map((f) => f.total)).toEqual([100, 75, 50]); + }); + + it('does not mutate the original array', function () { + const functions: FunctionData[] = [ + { + funcName: 'foo', + funcIndex: 0, + total: 50, + self: 30, + totalRelative: 0.25, + selfRelative: 0.15, + }, + { + funcName: 'bar', + funcIndex: 0, + total: 100, + self: 20, + totalRelative: 0.5, + selfRelative: 0.1, + }, + ]; + + const original = [...functions]; + sortByTotal(functions); + + expect(functions).toEqual(original); + }); + }); + + describe('sortBySelf', function () { + it('sorts functions by self time descending', function () { + const functions: FunctionData[] = [ + { + funcName: 'foo', + funcIndex: 0, + total: 100, + self: 30, + totalRelative: 0.5, + selfRelative: 0.15, + }, + { + funcName: 'bar', + funcIndex: 0, + total: 50, + self: 40, + totalRelative: 0.25, + selfRelative: 0.2, + }, + { + funcName: 'baz', + funcIndex: 0, + total: 75, + self: 20, + totalRelative: 0.375, + selfRelative: 0.1, + }, + ]; + + const sorted = sortBySelf(functions); + + expect(sorted.map((f) => f.funcName)).toEqual(['bar', 'foo', 'baz']); + expect(sorted.map((f) => f.self)).toEqual([40, 30, 20]); + }); + }); + + describe('formatFunctionList', function () { + it('formats a complete list with no omissions', function () { + const functions: FunctionData[] = [ + { + funcName: 'foo', + funcIndex: 0, + total: 100, + self: 50, + totalRelative: 0.5, + selfRelative: 0.25, + }, + { + funcName: 'bar', + funcIndex: 0, + total: 80, + self: 40, + totalRelative: 0.4, + selfRelative: 0.2, + }, + ]; + + const result = formatFunctionList( + 'Top Functions', + functions, + 10, + 'total', + new Set([0]), + new FunctionMap() + ); + + expect(result.title).toBe('Top Functions'); + expect(result.stats).toBeNull(); + expect(result.lines.length).toBe(2); + expect(result.lines[0]).toContain('foo'); + expect(result.lines[0]).toContain('total: 100'); + expect(result.lines[1]).toContain('bar'); + }); + + it('formats a list with omissions and shows stats', function () { + const functions: FunctionData[] = [ + { + funcName: 'func1', + funcIndex: 0, + total: 100, + self: 50, + totalRelative: 0.333, + selfRelative: 0.25, + }, + { + funcName: 'func2', + funcIndex: 0, + total: 90, + self: 40, + totalRelative: 0.3, + selfRelative: 0.2, + }, + { + funcName: 'func3', + funcIndex: 0, + total: 80, + self: 30, + totalRelative: 0.267, + selfRelative: 0.15, + }, + { + funcName: 'func4', + funcIndex: 0, + total: 70, + self: 20, + totalRelative: 0.233, + selfRelative: 0.1, + }, + { + funcName: 'func5', + funcIndex: 0, + total: 60, + self: 10, + totalRelative: 0.2, + selfRelative: 0.05, + }, + ]; + + const result = formatFunctionList( + 'Top Functions', + functions, + 3, + 'self', + new Set([0]), + new FunctionMap() + ); + + expect(result.title).toBe('Top Functions'); + expect(result.lines.length).toBe(5); // 3 functions + blank line + stats line + expect(result.stats).toEqual({ + omittedCount: 2, + maxTotal: 70, + maxSelf: 20, + sumSelf: 30, // 20 + 10 + }); + expect(result.lines[3]).toBe(''); + expect(result.lines[4]).toContain('2 more functions omitted'); + expect(result.lines[4]).toContain('max total: 70'); + expect(result.lines[4]).toContain('max self: 20'); + expect(result.lines[4]).toContain('sum of self: 30'); + }); + + it('formats entries with total first when sortKey is total', function () { + const functions: FunctionData[] = [ + { + funcName: 'foo', + funcIndex: 0, + total: 100, + self: 50, + totalRelative: 0.5, + selfRelative: 0.25, + }, + ]; + + const result = formatFunctionList( + 'Top Functions', + functions, + 10, + 'total', + new Set([0]), + new FunctionMap() + ); + + expect(result.lines[0]).toMatch(/total:.*self:/); + expect(result.lines[0]).toContain('total: 100 (50.0%)'); + expect(result.lines[0]).toContain('self: 50 (25.0%)'); + }); + + it('formats entries with self first when sortKey is self', function () { + const functions: FunctionData[] = [ + { + funcName: 'foo', + funcIndex: 0, + total: 100, + self: 50, + totalRelative: 0.5, + selfRelative: 0.25, + }, + ]; + + const result = formatFunctionList( + 'Top Functions', + functions, + 10, + 'self', + new Set([0]), + new FunctionMap() + ); + + expect(result.lines[0]).toMatch(/self:.*total:/); + expect(result.lines[0]).toContain('self: 50 (25.0%)'); + expect(result.lines[0]).toContain('total: 100 (50.0%)'); + }); + }); + + describe('createTopFunctionLists', function () { + it('creates two lists sorted by total and self', function () { + const functions: FunctionData[] = [ + { + funcName: 'highTotal', + funcIndex: 0, + total: 100, + self: 20, + totalRelative: 0.5, + selfRelative: 0.1, + }, + { + funcName: 'highSelf', + funcIndex: 0, + total: 50, + self: 40, + totalRelative: 0.25, + selfRelative: 0.2, + }, + { + funcName: 'mid', + funcIndex: 0, + total: 75, + self: 30, + totalRelative: 0.375, + selfRelative: 0.15, + }, + ]; + + const result = createTopFunctionLists( + functions, + 10, + new Set([0]), + new FunctionMap() + ); + + expect(result.byTotal.title).toBe('Top Functions (by total time)'); + expect(result.bySelf.title).toBe('Top Functions (by self time)'); + + // Check byTotal is sorted by total + expect(result.byTotal.lines[0]).toContain('highTotal'); + expect(result.byTotal.lines[1]).toContain('mid'); + expect(result.byTotal.lines[2]).toContain('highSelf'); + + // Check bySelf is sorted by self + expect(result.bySelf.lines[0]).toContain('highSelf'); + expect(result.bySelf.lines[1]).toContain('mid'); + expect(result.bySelf.lines[2]).toContain('highTotal'); + }); + + it('respects the limit and shows stats for omitted functions', function () { + const functions: FunctionData[] = [ + { + funcName: 'func1', + funcIndex: 0, + total: 100, + self: 50, + totalRelative: 0.4, + selfRelative: 0.2, + }, + { + funcName: 'func2', + funcIndex: 0, + total: 90, + self: 40, + totalRelative: 0.36, + selfRelative: 0.16, + }, + { + funcName: 'func3', + funcIndex: 0, + total: 80, + self: 30, + totalRelative: 0.32, + selfRelative: 0.12, + }, + ]; + + const result = createTopFunctionLists( + functions, + 2, + new Set([0]), + new FunctionMap() + ); + + // Each list should have 2 functions + blank + stats = 4 lines + expect(result.byTotal.lines.length).toBe(4); + expect(result.bySelf.lines.length).toBe(4); + + expect(result.byTotal.stats?.omittedCount).toBe(1); + expect(result.bySelf.stats?.omittedCount).toBe(1); + }); + }); + + describe('truncateFunctionName', function () { + it('returns names unchanged when they fit within the limit', function () { + expect(truncateFunctionName('RtlUserThreadStart', 120)).toBe( + 'RtlUserThreadStart' + ); + expect(truncateFunctionName('foo::bar::baz()', 120)).toBe( + 'foo::bar::baz()' + ); + expect( + truncateFunctionName('std::vector::push_back(int const&)', 120) + ).toBe('std::vector::push_back(int const&)'); + }); + + it('truncates simple C++ namespaced functions', function () { + const name = + 'some::very::long::namespace::hierarchy::with::many::levels::FunctionName()'; + const result = truncateFunctionName(name, 50); + + // Should preserve the function name at the end + expect(result).toContain('FunctionName()'); + // Should show some context at the beginning + expect(result).toContain('some::'); + expect(result.length).toBeLessThanOrEqual(50); + }); + + it('truncates complex template parameters intelligently', function () { + const name = + 'std::_Hash,std::equal_to>,std::allocator>,0>>::~_Hash()'; + const result = truncateFunctionName(name, 120); + + // Should preserve namespace prefix and function name + expect(result).toContain('std::_Hash<'); + expect(result).toContain('~_Hash()'); + // Should have collapsed some template parameters + expect(result.length).toBeLessThanOrEqual(120); + }); + + it('truncates function parameters while preserving function name', function () { + const name = + 'mozilla::wr::RenderThread::UpdateAndRender(mozilla::wr::WrWindowId, mozilla::layers::BaseTransactionId)'; + const result = truncateFunctionName(name, 120); + + // Function name should always be preserved + expect(result).toContain('UpdateAndRender('); + expect(result).toContain(')'); + // Should preserve context + expect(result).toContain('mozilla::wr::RenderThread::'); + expect(result.length).toBeLessThanOrEqual(120); + }); + + it('handles library prefixes correctly', function () { + const name = + 'nvoglv64.dll!mozilla::wr::RenderThread::UpdateAndRender(mozilla::wr::WrWindowId)'; + const result = truncateFunctionName(name, 120); + + // Library prefix should be preserved + expect(result).toStartWith('nvoglv64.dll!'); + // Function should still be visible + expect(result).toContain('UpdateAndRender'); + expect(result.length).toBeLessThanOrEqual(120); + }); + + it('handles very long library prefixes gracefully', function () { + const name = + 'a-very-long-library-name-that-is-too-long.dll!FunctionName()'; + const result = truncateFunctionName(name, 30); + + // Should fall back to simple truncation + expect(result.length).toBeLessThanOrEqual(30); + expect(result).toContain('...'); + }); + + it('truncates nested templates by collapsing inner content', function () { + const name = + 'mozilla::interceptor::FuncHook>::operator()'; + const result = truncateFunctionName(name, 120); + + // Should show outer template structure + expect(result).toContain('FuncHook<'); + expect(result).toContain('operator()'); + // Inner templates should be collapsed + expect(result.length).toBeLessThanOrEqual(120); + }); + + it('handles functions with no namespaces', function () { + const name = 'malloc'; + expect(truncateFunctionName(name, 120)).toBe('malloc'); + + const name2 = 'RtlUserThreadStart'; + expect(truncateFunctionName(name2, 120)).toBe('RtlUserThreadStart'); + }); + + it('handles empty parameters', function () { + expect(truncateFunctionName('foo::bar()', 120)).toBe('foo::bar()'); + expect(truncateFunctionName('SomeClass::Method()', 120)).toBe( + 'SomeClass::Method()' + ); + }); + + it('breaks at namespace boundaries when truncating prefix', function () { + const name = + 'namespace1::namespace2::namespace3::namespace4::namespace5::FunctionName()'; + const result = truncateFunctionName(name, 50); + + // Should break at :: boundaries, not mid-word + expect(result).not.toMatch(/[a-z]::[A-Z]/); // No broken words + expect(result).toContain('FunctionName()'); + expect(result.length).toBeLessThanOrEqual(50); + }); + + it('preserves closing parenthesis for functions with parameters', function () { + const name = 'SomeClass::Method(int, std::string, std::vector)'; + const result = truncateFunctionName(name, 40); + + // Should always have matching parentheses + expect(result).toContain('Method('); + expect(result).toContain(')'); + expect(result.length).toBeLessThanOrEqual(40); + }); + + it('handles deeply nested templates', function () { + const name = + 'std::vector>>>'; + const result = truncateFunctionName(name, 50); + + // Should show outer structure + expect(result).toContain('std::vector<'); + expect(result).toContain('>'); + // Should have collapsed inner content + expect(result.length).toBeLessThanOrEqual(50); + }); + + it('allocates more space to suffix (function name) when possible', function () { + const name = + 'short::VeryLongFunctionNameThatShouldBePreservedBecauseItIsImportant(parameter1, parameter2, parameter3)'; + const result = truncateFunctionName(name, 100); + + // Function name should be prioritized over prefix + expect(result).toContain('VeryLongFunctionName'); + expect(result.length).toBeLessThanOrEqual(100); + }); + + it('handles mixed templates and parameters', function () { + const name = + 'std::map::insert(std::pair const&)'; + const result = truncateFunctionName(name, 60); + + expect(result).toContain('insert('); + expect(result).toContain(')'); + expect(result.length).toBeLessThanOrEqual(60); + }); + + it('returns consistent results for the same input', function () { + const name = + 'mozilla::wr::RenderThread::UpdateAndRender(mozilla::wr::WrWindowId)'; + const result1 = truncateFunctionName(name, 100); + const result2 = truncateFunctionName(name, 100); + + expect(result1).toBe(result2); + }); + + it('handles edge case of very small maxLength', function () { + const name = 'SomeClass::SomeMethod()'; + const result = truncateFunctionName(name, 15); + + // Should still produce something reasonable and prioritize the function name + expect(result.length).toBeLessThanOrEqual(15); + expect(result.length).toBeGreaterThan(0); + // When space is very limited, it may drop the namespace to show the function name + expect(result).toContain('SomeMethod'); + }); + + it('handles names with only templates and no function name', function () { + const name = 'std::vector'; + const result = truncateFunctionName(name, 50); + + expect(result).toContain('std::vector<'); + expect(result.length).toBeLessThanOrEqual(50); + }); + + it('truncates while preserving critical structure markers', function () { + const name = 'foo::bar::qux(param1, param2, param3, param4)'; + const result = truncateFunctionName(name, 35); + + // Should maintain bracket pairing + const openAngles = (result.match(//g) || []).length; + const openParens = (result.match(/\(/g) || []).length; + const closeParens = (result.match(/\)/g) || []).length; + + // All opened brackets should be closed + expect(openAngles).toBe(closeAngles); + expect(openParens).toBe(closeParens); + expect(result.length).toBeLessThanOrEqual(35); + }); + }); +}); diff --git a/src/test/unit/profile-query/process-thread-list.test.ts b/src/test/unit/profile-query/process-thread-list.test.ts new file mode 100644 index 0000000000..f394e4fe7d --- /dev/null +++ b/src/test/unit/profile-query/process-thread-list.test.ts @@ -0,0 +1,379 @@ +/* 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/. */ + +import { buildProcessThreadList } from 'firefox-profiler/profile-query/process-thread-list'; + +import type { ThreadInfo } from 'firefox-profiler/profile-query/process-thread-list'; + +describe('buildProcessThreadList', function () { + function createThread( + threadIndex: number, + pid: string, + name: string, + cpuMs: number + ): ThreadInfo { + return { threadIndex, pid, name, cpuMs }; + } + + it('shows top 5 processes by CPU, plus any needed for top 20 threads', function () { + // All 7 threads are in top 20, so all 7 processes should be shown + const threads: ThreadInfo[] = [ + createThread(0, 'p1', 'Thread1', 100), + createThread(1, 'p2', 'Thread2', 80), + createThread(2, 'p3', 'Thread3', 60), + createThread(3, 'p4', 'Thread4', 40), + createThread(4, 'p5', 'Thread5', 20), + createThread(5, 'p6', 'Thread6', 10), + createThread(6, 'p7', 'Thread7', 5), + ]; + + const processIndexMap = new Map([ + ['p1', 0], + ['p2', 1], + ['p3', 2], + ['p4', 3], + ['p5', 4], + ['p6', 5], + ['p7', 6], + ]); + + const result = buildProcessThreadList(threads, processIndexMap); + + // All 7 threads are in top 20, so all 7 processes are shown + expect(result.processes.length).toBe(7); + expect(result.processes.map((p) => p.pid)).toEqual([ + 'p1', + 'p2', + 'p3', + 'p4', + 'p5', + 'p6', + 'p7', + ]); + }); + + it('includes processes with threads in top 20, even if not in top 5 processes', function () { + // Process p1 has high CPU from one thread + // Process p2 has low CPU total but has a thread in the top 20 + const threads: ThreadInfo[] = [ + createThread(0, 'p1', 'Thread1', 100), + createThread(1, 'p1', 'Thread2', 1), + createThread(2, 'p1', 'Thread3', 1), + createThread(3, 'p2', 'HighCPU', 50), // This thread is in top 20 + createThread(4, 'p2', 'LowCPU', 0.5), + createThread(5, 'p3', 'Thread6', 80), + createThread(6, 'p4', 'Thread7', 70), + createThread(7, 'p5', 'Thread8', 60), + createThread(8, 'p6', 'Thread9', 55), + ]; + + const processIndexMap = new Map([ + ['p1', 0], + ['p2', 1], + ['p3', 2], + ['p4', 3], + ['p5', 4], + ['p6', 5], + ]); + + const result = buildProcessThreadList(threads, processIndexMap); + + // Should include p2 even though it's not in top 5 by total CPU + // because it has a thread (t3) in the top 20 + expect(result.processes.map((p) => p.pid)).toContain('p2'); + }); + + it('shows up to 5 threads per process when none are in top 20', function () { + // Create 4 high-CPU processes that will be in top 5 + const threads: ThreadInfo[] = []; + threads.push(createThread(0, 'p-high-0', 'High1', 10000)); + threads.push(createThread(1, 'p-high-1', 'High2', 9000)); + threads.push(createThread(2, 'p-high-2', 'High3', 8000)); + threads.push(createThread(3, 'p-high-3', 'High4', 7000)); + + // p1 will be 5th by total CPU (with many threads but none in top 20) + threads.push(createThread(10, 'p1', 'Thread1', 600)); + threads.push(createThread(11, 'p1', 'Thread2', 500)); + threads.push(createThread(12, 'p1', 'Thread3', 400)); + threads.push(createThread(13, 'p1', 'Thread4', 300)); + threads.push(createThread(14, 'p1', 'Thread5', 200)); + threads.push(createThread(15, 'p1', 'Thread6', 100)); + threads.push(createThread(16, 'p1', 'Thread7', 50)); + // p1 total: 2150ms, should be 5th place + + // Add threads that will fill positions 5-20 in top 20, pushing out p1's threads + threads.push(createThread(4, 'p2', 'Med1', 6000)); + threads.push(createThread(5, 'p2', 'Med2', 5000)); + threads.push(createThread(6, 'p3', 'Med3', 4000)); + threads.push(createThread(7, 'p3', 'Med4', 3000)); + threads.push(createThread(8, 'p4', 'Med5', 2000)); + threads.push(createThread(9, 'p4', 'Med6', 1000)); + threads.push(createThread(20, 'p5', 'Med7', 900)); + threads.push(createThread(21, 'p5', 'Med8', 800)); + threads.push(createThread(22, 'p6', 'Med9', 700)); + threads.push(createThread(23, 'p6', 'Med10', 650)); + threads.push(createThread(24, 'p7', 'Med11', 640)); + threads.push(createThread(25, 'p7', 'Med12', 630)); + threads.push(createThread(26, 'p8', 'Med13', 620)); + threads.push(createThread(27, 'p8', 'Med14', 610)); + // Top 20 are now: 10000, 9000, 8000, 7000, 6000, 5000, 4000, 3000, 2000, 1000, 900, 800, 700, 650, 640, 630, 620, 610, 600, 500 + // p1's highest is 600ms (position 19) and 500ms (position 20) + + const processIndexMap = new Map([ + ['p-high-0', 0], + ['p-high-1', 1], + ['p-high-2', 2], + ['p-high-3', 3], + ['p1', 4], + ['p2', 5], + ['p3', 6], + ['p4', 7], + ['p5', 8], + ['p6', 9], + ['p7', 10], + ['p8', 11], + ]); + + const result = buildProcessThreadList(threads, processIndexMap); + + const p1 = result.processes.find((p) => p.pid === 'p1'); + expect(p1).toBeDefined(); + // t10 and t11 from p1 are in top 20, plus we fill up to 5 total + expect(p1!.threads.length).toBe(5); + // Should show the 2 from top 20 plus the next 3 highest + expect(p1!.threads.map((t) => t.threadIndex)).toEqual([10, 11, 12, 13, 14]); + }); + + it('includes summary for remaining threads', function () { + // Create scenario where only some threads from p1 are in top 20 + const threads: ThreadInfo[] = []; + + // Add 15 high-CPU threads from other processes + for (let i = 0; i < 15; i++) { + threads.push( + createThread(i, `p-high-${i}`, `HighCPU${i}`, 1000 - i * 10) + ); + } + + // Add p1 threads - the first 5 will be in top 20 (850ms is above 910ms cutoff) + threads.push(createThread(15, 'p1', 'Thread1', 950)); // In top 20 + threads.push(createThread(16, 'p1', 'Thread2', 940)); // In top 20 + threads.push(createThread(17, 'p1', 'Thread3', 930)); // In top 20 + threads.push(createThread(18, 'p1', 'Thread4', 920)); // In top 20 + threads.push(createThread(19, 'p1', 'Thread5', 910)); // In top 20 (20th place) + // These are not in top 20 + threads.push(createThread(20, 'p1', 'Thread6', 50)); + threads.push(createThread(21, 'p1', 'Thread7', 40)); + threads.push(createThread(22, 'p1', 'Thread8', 30)); + + const processIndexMap = new Map([['p1', 100]]); + for (let i = 0; i < 15; i++) { + processIndexMap.set(`p-high-${i}`, i); + } + + const result = buildProcessThreadList(threads, processIndexMap); + + const p1 = result.processes.find((p) => p.pid === 'p1'); + expect(p1).toBeDefined(); + + // Should show 5 top-20 threads + expect(p1!.threads.length).toBe(5); + expect(p1!.threads.map((t) => t.threadIndex)).toEqual([15, 16, 17, 18, 19]); + + // Should have remaining threads summary + expect(p1!.remainingThreads).toEqual({ + count: 3, + combinedCpuMs: 120, // 50 + 40 + 30 + maxCpuMs: 50, + }); + }); + + it('shows ALL top-20 threads from a process, even if more than 5', function () { + // This is the critical test case for the bug fix: + // If a process has 7 threads in the top 20, all 7 should be shown, + // not just the first 5. + const threads: ThreadInfo[] = [ + // Process p1 has 7 threads in the top 20 + createThread(0, 'p1', 'Thread1', 100), + createThread(1, 'p1', 'Thread2', 95), + createThread(2, 'p1', 'Thread3', 90), + createThread(3, 'p1', 'Thread4', 85), + createThread(4, 'p1', 'Thread5', 80), + createThread(5, 'p1', 'Thread6', 75), + createThread(6, 'p1', 'Thread7', 70), + // These threads from p1 are not in top 20 + createThread(7, 'p1', 'Thread8', 5), + createThread(8, 'p1', 'Thread9', 4), + // Other processes to fill out the top 20 + createThread(9, 'p2', 'Thread10', 65), + createThread(10, 'p2', 'Thread11', 60), + createThread(11, 'p3', 'Thread12', 55), + createThread(12, 'p3', 'Thread13', 50), + createThread(13, 'p4', 'Thread14', 45), + createThread(14, 'p4', 'Thread15', 40), + createThread(15, 'p5', 'Thread16', 35), + createThread(16, 'p5', 'Thread17', 30), + createThread(17, 'p6', 'Thread18', 25), + createThread(18, 'p6', 'Thread19', 20), + createThread(19, 'p7', 'Thread20', 15), + createThread(20, 'p7', 'Thread21', 10), + createThread(21, 'p8', 'Thread22', 9), + createThread(22, 'p8', 'Thread23', 8), + createThread(23, 'p9', 'Thread24', 7), + createThread(24, 'p9', 'Thread25', 6), + // More threads below top 20 - these push out t7 and t8 from p1 + ]; + + const processIndexMap = new Map([ + ['p1', 0], + ['p2', 1], + ['p3', 2], + ['p4', 3], + ['p5', 4], + ['p6', 5], + ['p7', 6], + ['p8', 7], + ['p9', 8], + ]); + + const result = buildProcessThreadList(threads, processIndexMap); + + const p1 = result.processes.find((p) => p.pid === 'p1'); + expect(p1).toBeDefined(); + + // Should show all 7 threads from top 20, not just 5 + expect(p1!.threads.length).toBe(7); + expect(p1!.threads.map((t) => t.threadIndex)).toEqual([ + 0, 1, 2, 3, 4, 5, 6, + ]); + + // Should have remaining threads summary for the 2 threads not in top 20 + expect(p1!.remainingThreads).toEqual({ + count: 2, + combinedCpuMs: 9, // 5 + 4 + maxCpuMs: 5, + }); + }); + + it('sorts threads by CPU within each process', function () { + const threads: ThreadInfo[] = [ + createThread(0, 'p1', 'Low', 10), + createThread(1, 'p1', 'High', 100), + createThread(2, 'p1', 'Medium', 50), + ]; + + const processIndexMap = new Map([['p1', 0]]); + + const result = buildProcessThreadList(threads, processIndexMap); + + expect(result.processes[0].threads.map((t) => t.name)).toEqual([ + 'High', + 'Medium', + 'Low', + ]); + }); + + it('handles empty thread list', function () { + const threads: ThreadInfo[] = []; + const processIndexMap = new Map(); + + const result = buildProcessThreadList(threads, processIndexMap); + + expect(result.processes).toEqual([]); + expect(result.remainingProcesses).toBeUndefined(); + }); + + it('handles single thread', function () { + const threads: ThreadInfo[] = [createThread(0, 'p1', 'OnlyThread', 100)]; + + const processIndexMap = new Map([['p1', 0]]); + + const result = buildProcessThreadList(threads, processIndexMap); + + expect(result.processes.length).toBe(1); + expect(result.processes[0].threads.length).toBe(1); + expect(result.processes[0].remainingThreads).toBeUndefined(); + expect(result.remainingProcesses).toBeUndefined(); + }); + + it('correctly aggregates CPU time per process', function () { + const threads: ThreadInfo[] = [ + createThread(0, 'p1', 'Thread1', 100), + createThread(1, 'p1', 'Thread2', 50), + createThread(2, 'p1', 'Thread3', 25), + createThread(3, 'p2', 'Thread4', 200), + ]; + + const processIndexMap = new Map([ + ['p1', 0], + ['p2', 1], + ]); + + const result = buildProcessThreadList(threads, processIndexMap); + + const p1 = result.processes.find((p) => p.pid === 'p1'); + const p2 = result.processes.find((p) => p.pid === 'p2'); + + expect(p1!.cpuMs).toBe(175); // 100 + 50 + 25 + expect(p2!.cpuMs).toBe(200); + }); + + it('includes summary for remaining processes', function () { + // Create a scenario with many processes, where only some are shown + // We need the top 5 processes to be shown, but processes 6-10 should NOT have + // any threads in the top 20 overall + const threads: ThreadInfo[] = []; + + // Add 20 high-CPU threads from top 5 processes + // Each of these processes gets 4 threads in the top 20 + for (let procNum = 0; procNum < 5; procNum++) { + for (let threadNum = 0; threadNum < 4; threadNum++) { + const threadIndex = procNum * 4 + threadNum; + const cpuMs = 1000 - threadIndex * 10; // 1000, 990, 980, ... down to 810 + threads.push( + createThread( + threadIndex, + `p${procNum}`, + `Thread${threadIndex}`, + cpuMs + ) + ); + } + } + + // Add 5 more processes with low CPU (not in top 20) + // These should not be shown + for (let procNum = 5; procNum < 10; procNum++) { + const threadIndex = 20 + procNum - 5; + const cpuMs = 50 - (procNum - 5) * 10; // 50, 40, 30, 20, 10 + threads.push( + createThread(threadIndex, `p${procNum}`, `Thread${threadIndex}`, cpuMs) + ); + } + + const processIndexMap = new Map(); + for (let i = 0; i < 10; i++) { + processIndexMap.set(`p${i}`, i); + } + + const result = buildProcessThreadList(threads, processIndexMap); + + // Should show only top 5 processes (those with threads in top 20) + expect(result.processes.length).toBe(5); + expect(result.processes.map((p) => p.pid)).toEqual([ + 'p0', + 'p1', + 'p2', + 'p3', + 'p4', + ]); + + // Should have remaining processes summary for the last 5 processes + expect(result.remainingProcesses).toEqual({ + count: 5, + combinedCpuMs: 150, // 50 + 40 + 30 + 20 + 10 + maxCpuMs: 50, + }); + }); +}); diff --git a/src/test/unit/profile-query/profile-querier.test.ts b/src/test/unit/profile-query/profile-querier.test.ts new file mode 100644 index 0000000000..cd4c01dd0a --- /dev/null +++ b/src/test/unit/profile-query/profile-querier.test.ts @@ -0,0 +1,210 @@ +/* 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/. */ + +/** + * Unit tests for ProfileQuerier class. + * + * NOTE: Currently minimal tests. + * + * The ProfileQuerier class is tested through integration tests in bash scripts + * (bin/pq-test) that load real profiles and verify the output. + * + * Unit tests can be added here for specific utility methods or edge cases that + * are easier to test in isolation. The summarize() method uses the + * buildProcessThreadList function which is thoroughly tested in + * process-thread-list.test.ts. + */ + +import { ProfileQuerier } from 'firefox-profiler/profile-query'; +import { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; +import { getProfileRootRange } from 'firefox-profiler/selectors/profile'; +import { storeWithProfile } from '../../fixtures/stores'; + +describe('ProfileQuerier', function () { + describe('pushViewRange', function () { + it('changes thread samples output to show functions in the selected range', async function () { + // Create a profile with samples at different times that have different call stacks + // Time 0-10ms: call stack has functions A, B, C + // Time 10-20ms: call stack has functions A, B, D + // Time 20-30ms: call stack has functions A, B, E + const { profile } = getProfileFromTextSamples(` + 0 10 20 + A A A + B B B + C D E + `); + + // Set up the store with the profile + const store = storeWithProfile(profile); + const state = store.getState(); + const rootRange = getProfileRootRange(state); + + // Create ProfileQuerier + const querier = new ProfileQuerier(store, rootRange); + + // Get baseline thread samples (should show all functions A, B, C, D, E) + // Don't pass thread handle - use default selected thread + const baselineSamples = await querier.threadSamples(); + const allFunctions = [ + ...baselineSamples.topFunctionsByTotal.map((f) => f.name), + ...baselineSamples.topFunctionsBySelf.map((f) => f.name), + ].join(' '); + expect(allFunctions).toContain('A'); + expect(allFunctions).toContain('B'); + // At least some of C, D, E should appear + const hasC = allFunctions.includes('C'); + const hasD = allFunctions.includes('D'); + const hasE = allFunctions.includes('E'); + expect(hasC || hasD || hasE).toBe(true); + + // Create timestamp names for a narrower range + // The profile has samples at 0ms, 10ms, 20ms + // Select from just after start to just before end to focus on middle sample + const startName = querier._timestampManager.nameForTimestamp( + rootRange.start + 8 + ); + const endName = querier._timestampManager.nameForTimestamp( + rootRange.start + 12 + ); + + // Push a range that includes only the middle sample (at 10ms) + // This should focus on the call stack with D + await querier.pushViewRange(`${startName},${endName}`); + + // Get thread samples again - should now focus on the selected range + const rangedSamples = await querier.threadSamples(); + + // The output should still contain A and B (common to all stacks) + const rangedAllFunctions = [ + ...rangedSamples.topFunctionsByTotal.map((f) => f.name), + ...rangedSamples.topFunctionsBySelf.map((f) => f.name), + ].join(' '); + expect(rangedAllFunctions).toContain('A'); + expect(rangedAllFunctions).toContain('B'); + + // After pushing a range, the samples should be different from baseline + expect(rangedSamples).not.toBe(baselineSamples); + }); + + it('popViewRange restores the previous view', async function () { + const { profile } = getProfileFromTextSamples(` + 0 10 20 + A A A + B B B + C D E + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const rootRange = getProfileRootRange(state); + + const querier = new ProfileQuerier(store, rootRange); + + // Get baseline samples + const baselineSamples = await querier.threadSamples(); + + // Create timestamp names and push a range + const startName = querier._timestampManager.nameForTimestamp( + rootRange.start + 5 + ); + const endName = querier._timestampManager.nameForTimestamp( + rootRange.start + 15 + ); + await querier.pushViewRange(`${startName},${endName}`); + const rangedSamples = await querier.threadSamples(); + + // Samples should be different after push + expect(rangedSamples).not.toBe(baselineSamples); + + // Pop the range + const popResult = await querier.popViewRange(); + expect(popResult.message).toContain('Popped view range'); + + // Samples should be back to baseline (or at least different from ranged) + const afterPopSamples = await querier.threadSamples(); + expect(afterPopSamples).not.toBe(rangedSamples); + }); + + it('shows non-empty output after pushing a range with samples', async function () { + // Create a profile with many samples across a longer time range + const { profile } = getProfileFromTextSamples(` + 0 1 2 3 4 5 6 7 8 9 10 11 12 + A A A A A A A A A A A A A + B B B B B B B B B B B B B + C C C D D D E E E F F F G + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const rootRange = getProfileRootRange(state); + + const querier = new ProfileQuerier(store, rootRange); + + // Push a range that includes samples in the middle (5-8ms should include samples at 5, 6, 7, 8) + const startName = querier._timestampManager.nameForTimestamp( + rootRange.start + 5 + ); + const endName = querier._timestampManager.nameForTimestamp( + rootRange.start + 8 + ); + await querier.pushViewRange(`${startName},${endName}`); + + const rangedSamples = await querier.threadSamples(); + + // The output should NOT be empty - it should contain functions from the selected range + const rangedFunctions = [ + ...rangedSamples.topFunctionsByTotal.map((f) => f.name), + ...rangedSamples.topFunctionsBySelf.map((f) => f.name), + ].join(' '); + expect(rangedFunctions).toContain('A'); + expect(rangedFunctions).toContain('B'); + + // Should show D and/or E (which are in the range) + const hasD = rangedFunctions.includes('D'); + const hasE = rangedFunctions.includes('E'); + expect(hasD || hasE).toBe(true); + + // Should show actual function data, not empty sections + expect(rangedSamples.topFunctionsByTotal.length).toBeGreaterThan(0); + expect(rangedSamples.topFunctionsBySelf.length).toBeGreaterThan(0); + }); + + it('works correctly with absolute timestamps and non-zero profile start', async function () { + // Create a profile that starts at 1000ms (not zero) + const { profile } = getProfileFromTextSamples(` + 1000 1005 1010 1015 1020 + A A A A A + B B B B B + C D E F G + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const rootRange = getProfileRootRange(state); + + const querier = new ProfileQuerier(store, rootRange); + + // Push a range using absolute timestamps + // pushViewRange should convert these to relative timestamps for commitRange + const startName = querier._timestampManager.nameForTimestamp(1005); + const endName = querier._timestampManager.nameForTimestamp(1015); + await querier.pushViewRange(`${startName},${endName}`); + + const rangedSamples = await querier.threadSamples(); + + // Should contain functions from the selected range (1005-1015ms) + const rangedFunctions2 = [ + ...rangedSamples.topFunctionsByTotal.map((f) => f.name), + ...rangedSamples.topFunctionsBySelf.map((f) => f.name), + ].join(' '); + expect(rangedFunctions2).toContain('A'); + expect(rangedFunctions2).toContain('B'); + + // Should contain D and E which are in the middle of the range + const hasD = rangedFunctions2.includes('D'); + const hasE = rangedFunctions2.includes('E'); + expect(hasD || hasE).toBe(true); + }); + }); +}); diff --git a/src/test/unit/profile-query/time-range-parser.test.ts b/src/test/unit/profile-query/time-range-parser.test.ts new file mode 100644 index 0000000000..1091cc48af --- /dev/null +++ b/src/test/unit/profile-query/time-range-parser.test.ts @@ -0,0 +1,145 @@ +/* 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/. */ + +import { parseTimeValue } from '../../../profile-query/time-range-parser'; +import type { StartEndRange } from 'firefox-profiler/types'; + +describe('parseTimeValue', () => { + const rootRange: StartEndRange = { + start: 1000, + end: 11000, + }; + + describe('timestamp names', () => { + it('returns null for timestamp names', () => { + expect(parseTimeValue('ts-0', rootRange)).toBe(null); + expect(parseTimeValue('ts-6', rootRange)).toBe(null); + expect(parseTimeValue('ts-Z', rootRange)).toBe(null); + expect(parseTimeValue('ts<0', rootRange)).toBe(null); + expect(parseTimeValue('ts>1', rootRange)).toBe(null); + }); + }); + + describe('seconds (no suffix)', () => { + it('parses seconds as default format', () => { + expect(parseTimeValue('0', rootRange)).toBe(1000); + expect(parseTimeValue('1', rootRange)).toBe(2000); + expect(parseTimeValue('5', rootRange)).toBe(6000); + expect(parseTimeValue('10', rootRange)).toBe(11000); + }); + + it('parses decimal seconds', () => { + expect(parseTimeValue('0.5', rootRange)).toBe(1500); + expect(parseTimeValue('2.7', rootRange)).toBe(3700); + expect(parseTimeValue('3.14', rootRange)).toBe(4140); + }); + + it('handles leading zeros', () => { + expect(parseTimeValue('0.001', rootRange)).toBe(1001); + expect(parseTimeValue('00.5', rootRange)).toBe(1500); + }); + }); + + describe('seconds with suffix', () => { + it('parses seconds with "s" suffix', () => { + expect(parseTimeValue('0s', rootRange)).toBe(1000); + expect(parseTimeValue('1s', rootRange)).toBe(2000); + expect(parseTimeValue('5s', rootRange)).toBe(6000); + }); + + it('parses decimal seconds with "s" suffix', () => { + expect(parseTimeValue('0.5s', rootRange)).toBe(1500); + expect(parseTimeValue('2.7s', rootRange)).toBe(3700); + }); + }); + + describe('milliseconds', () => { + it('parses milliseconds', () => { + expect(parseTimeValue('0ms', rootRange)).toBe(1000); + expect(parseTimeValue('1000ms', rootRange)).toBe(2000); + expect(parseTimeValue('2700ms', rootRange)).toBe(3700); + expect(parseTimeValue('10000ms', rootRange)).toBe(11000); + }); + + it('parses decimal milliseconds', () => { + expect(parseTimeValue('500ms', rootRange)).toBe(1500); + expect(parseTimeValue('0.5ms', rootRange)).toBe(1000.5); + }); + }); + + describe('percentages', () => { + it('parses percentages of profile duration', () => { + // Profile duration is 10000ms (11000 - 1000) + expect(parseTimeValue('0%', rootRange)).toBe(1000); + expect(parseTimeValue('10%', rootRange)).toBe(2000); + expect(parseTimeValue('50%', rootRange)).toBe(6000); + expect(parseTimeValue('100%', rootRange)).toBe(11000); + }); + + it('parses decimal percentages', () => { + expect(parseTimeValue('5%', rootRange)).toBe(1500); + expect(parseTimeValue('25%', rootRange)).toBe(3500); + expect(parseTimeValue('17%', rootRange)).toBe(2700); + }); + + it('handles percentages over 100%', () => { + expect(parseTimeValue('150%', rootRange)).toBe(16000); + }); + }); + + describe('error handling', () => { + it('throws on invalid seconds', () => { + expect(() => parseTimeValue('abc', rootRange)).toThrow( + 'Invalid time value' + ); + expect(() => parseTimeValue('', rootRange)).toThrow('Invalid time value'); + }); + + it('throws on invalid milliseconds', () => { + expect(() => parseTimeValue('abcms', rootRange)).toThrow( + 'Invalid milliseconds' + ); + expect(() => parseTimeValue('ms', rootRange)).toThrow( + 'Invalid milliseconds' + ); + }); + + it('throws on invalid percentages', () => { + expect(() => parseTimeValue('abc%', rootRange)).toThrow( + 'Invalid percentage' + ); + expect(() => parseTimeValue('%', rootRange)).toThrow( + 'Invalid percentage' + ); + }); + + it('throws on invalid seconds with suffix', () => { + expect(() => parseTimeValue('abcs', rootRange)).toThrow( + 'Invalid seconds' + ); + expect(() => parseTimeValue('s', rootRange)).toThrow('Invalid seconds'); + }); + }); + + describe('edge cases', () => { + it('handles negative values', () => { + expect(parseTimeValue('-1', rootRange)).toBe(0); + expect(parseTimeValue('-1s', rootRange)).toBe(0); + expect(parseTimeValue('-1000ms', rootRange)).toBe(0); + }); + + it('handles very large values', () => { + // 1000000 seconds = 1000000000ms, plus rootRange.start (1000ms) + expect(parseTimeValue('1000000', rootRange)).toBe(1000001000); + expect(parseTimeValue('1000000s', rootRange)).toBe(1000001000); + }); + + it('handles zero', () => { + expect(parseTimeValue('0', rootRange)).toBe(1000); + expect(parseTimeValue('0s', rootRange)).toBe(1000); + expect(parseTimeValue('0ms', rootRange)).toBe(1000); + expect(parseTimeValue('0%', rootRange)).toBe(1000); + }); + }); +}); diff --git a/src/test/unit/profile-query/timestamps.test.ts b/src/test/unit/profile-query/timestamps.test.ts new file mode 100644 index 0000000000..35113ab0b9 --- /dev/null +++ b/src/test/unit/profile-query/timestamps.test.ts @@ -0,0 +1,133 @@ +/* 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/. */ + +import { TimestampManager } from 'firefox-profiler/profile-query/timestamps'; + +/** + * Unit tests for TimestampManager class. + */ + +describe('TimestampManager', function () { + describe('in-range timestamps', function () { + it('assigns short hierarchical names', function () { + const m = new TimestampManager({ start: 1000, end: 2000 }); + expect(m.nameForTimestamp(1000)).toBe('ts-0'); + expect(m.nameForTimestamp(2000)).toBe('ts-Z'); + expect(m.nameForTimestamp(1500)).toBe('ts-K'); + expect(m.nameForTimestamp(1002)).toBe('ts-1'); + expect(m.nameForTimestamp(1000.1)).toBe('ts-04'); + expect(m.nameForTimestamp(1001)).toBe('ts-0K'); + expect(m.nameForTimestamp(1006)).toBe('ts-2'); + }); + }); + + describe('before-range timestamps', function () { + it('uses ts< prefix with exponential buckets', function () { + const m = new TimestampManager({ start: 1000, end: 2000 }); + // Range length = 1000 + // ts<0 covers [0, 1000] (1×length before start) + // ts<1 covers [-1000, 0] (2×length before start) + // ts<2 covers [-3000, -1000] (4×length before start) + + // Timestamps in bucket 0 + expect(m.nameForTimestamp(500)).toMatch(/^ts<0/); + expect(m.nameForTimestamp(999)).toMatch(/^ts<0/); + + // Timestamps in bucket 1 + expect(m.nameForTimestamp(-500)).toMatch(/^ts<1/); + expect(m.nameForTimestamp(-999)).toMatch(/^ts<1/); + + // Timestamps in bucket 2 + expect(m.nameForTimestamp(-1500)).toMatch(/^ts<2/); + expect(m.nameForTimestamp(-2999)).toMatch(/^ts<2/); + }); + + it('creates hierarchical names within buckets', function () { + const m = new TimestampManager({ start: 1000, end: 2000 }); + // Request two timestamps and verify they get valid bucket-0 names + const name1 = m.nameForTimestamp(500); + const name2 = m.nameForTimestamp(250); + + expect(name1).toMatch(/^ts<0[0-9a-zA-Z]+$/); + expect(name2).toMatch(/^ts<0[0-9a-zA-Z]+$/); + + // They should be different names + expect(name1).not.toBe(name2); + }); + }); + + describe('after-range timestamps', function () { + it('uses ts> prefix with exponential buckets', function () { + const m = new TimestampManager({ start: 1000, end: 2000 }); + // Range length = 1000 + // ts>0 covers [2000, 3000] (1×length after end) + // ts>1 covers [3000, 4000] (2×length after end) + // ts>2 covers [4000, 6000] (4×length after end) + + // Timestamps in bucket 0 + expect(m.nameForTimestamp(2500)).toMatch(/^ts>0/); + expect(m.nameForTimestamp(2999)).toMatch(/^ts>0/); + + // Timestamps in bucket 1 + expect(m.nameForTimestamp(3500)).toMatch(/^ts>1/); + expect(m.nameForTimestamp(3999)).toMatch(/^ts>1/); + + // Timestamps in bucket 2 + expect(m.nameForTimestamp(5000)).toMatch(/^ts>2/); + expect(m.nameForTimestamp(5999)).toMatch(/^ts>2/); + }); + + it('creates hierarchical names within buckets', function () { + const m = new TimestampManager({ start: 1000, end: 2000 }); + // Request two timestamps and verify they get valid bucket-0 names + const name1 = m.nameForTimestamp(2500); + const name2 = m.nameForTimestamp(2750); + + expect(name1).toMatch(/^ts>0[0-9a-zA-Z]+$/); + expect(name2).toMatch(/^ts>0[0-9a-zA-Z]+$/); + + // They should be different names + expect(name1).not.toBe(name2); + }); + }); + + describe('reverse lookup', function () { + it('returns timestamps for names that were previously minted', function () { + const m = new TimestampManager({ start: 1000, end: 2000 }); + + // Mint some names + const name1 = m.nameForTimestamp(1000); + const name2 = m.nameForTimestamp(1500); + const name3 = m.nameForTimestamp(500); + const name4 = m.nameForTimestamp(2500); + + // Reverse lookup should work + expect(m.timestampForName(name1)).toBe(1000); + expect(m.timestampForName(name2)).toBe(1500); + expect(m.timestampForName(name3)).toBe(500); + expect(m.timestampForName(name4)).toBe(2500); + }); + + it('returns null for unknown names', function () { + const m = new TimestampManager({ start: 1000, end: 2000 }); + expect(m.timestampForName('ts-X')).toBe(null); + expect(m.timestampForName('ts<0Y')).toBe(null); + expect(m.timestampForName('unknown')).toBe(null); + }); + + it('handles repeated requests for the same timestamp', function () { + const m = new TimestampManager({ start: 1000, end: 2000 }); + + // Request same timestamp twice + const name1 = m.nameForTimestamp(1500); + const name2 = m.nameForTimestamp(1500); + + // Should get the same name + expect(name1).toBe(name2); + + // Reverse lookup should work + expect(m.timestampForName(name1)).toBe(1500); + }); + }); +}); From aed86e146960be6ab4df974f23b29a71df9ab72a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 23 Feb 2026 17:17:34 +0100 Subject: [PATCH 04/63] Do not run profile-query-cli tests in `yarn test` A `yarn test:cli` script was added to run only the cli tests, but it looks like the main tests are also running them since they match the testMatch glob. --- jest.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/jest.config.js b/jest.config.js index 59ac61ffcd..347b2a15b2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,6 +10,7 @@ module.exports = { { displayName: 'browser', testMatch: ['/src/**/*.test.{js,jsx,ts,tsx}'], + testPathIgnorePatterns: ['/src/profile-query-cli/tests/'], moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], // Use custom resolver that respects the "browser" field in package.json From f982f2f167dac562eaa2647ef4baf0221ebc64c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 23 Feb 2026 17:30:58 +0100 Subject: [PATCH 05/63] Ignore the cli dist directory in eslint and prettier --- .prettierignore | 1 + eslint.config.mjs | 1 + 2 files changed, 2 insertions(+) diff --git a/.prettierignore b/.prettierignore index 93d9f5be03..abee240400 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ src/profile-logic/import/proto src/types/libdef/npm +src/profile-query-cli/dist docs-user/js docs-user/css src/test/fixtures/upgrades diff --git a/eslint.config.mjs b/eslint.config.mjs index 8c4f1150dd..705e2c0d1a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,6 +18,7 @@ export default defineConfig( ignores: [ 'src/profile-logic/import/proto/**', 'src/types/libdef/npm/**', + 'src/profile-query-cli/dist/**', 'res/**', 'dist/**', 'docs-user/**', From 11e0ce3de0351c20408355976ff48ee2685b6078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 11 Mar 2026 17:34:23 +0100 Subject: [PATCH 06/63] Add a --guide flag to pq --- scripts/build-profile-query-cli.mjs | 1 + src/profile-query-cli/guide.txt | 173 ++++++++++++++++++++++++++++ src/profile-query-cli/index.ts | 24 +++- src/types/globals/global.d.ts | 5 + 4 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 src/profile-query-cli/guide.txt diff --git a/scripts/build-profile-query-cli.mjs b/scripts/build-profile-query-cli.mjs index 43d51413de..1627abb842 100644 --- a/scripts/build-profile-query-cli.mjs +++ b/scripts/build-profile-query-cli.mjs @@ -10,6 +10,7 @@ const BUILD_HASH = Date.now().toString(36); const profileQueryCliConfig = { ...nodeBaseConfig, entryPoints: ['src/profile-query-cli/index.ts'], + loader: { ...nodeBaseConfig.loader, '.txt': 'text' }, outfile: 'src/profile-query-cli/dist/pq.js', minify: true, banner: { diff --git a/src/profile-query-cli/guide.txt b/src/profile-query-cli/guide.txt new file mode 100644 index 0000000000..503cd38396 --- /dev/null +++ b/src/profile-query-cli/guide.txt @@ -0,0 +1,173 @@ +pq: Usage Guide + +pq (Profile Querier) queries Firefox performance profiles loaded into a persistent +daemon. Load a profile once, then query it interactively without reloading. + + +SESSION MODEL + + pq uses a daemon/session model: + 1. Load a profile with "pq load" -- starts a background daemon + 2. Query the loaded profile with any other commands + 3. Stop with "pq stop" when done + + The daemon persists your selected thread and zoom ranges across queries. + + +CORE WORKFLOW + + Step 1: Load a profile + pq load profile.json.gz + + Step 2: Explore the profile structure + pq profile info Overview: processes, threads, time range, CPU activity + + Step 3: Select a thread to analyze + pq thread select t-0 Select a specific thread (persists for future commands) + + Step 4: Analyze CPU usage + pq thread samples Hot functions list (self time, sorted by impact) + pq thread samples-top-down Top-down call tree (where does CPU time originate) + pq thread samples-bottom-up Bottom-up call tree (what calls the hot functions) + pq thread functions All functions with self/total CPU percentages + + Step 5: Analyze markers (browser events, timers, etc.) + pq thread markers All markers with aggregated stats + pq thread markers --category Layout Filter by category + pq thread markers --search DOMEvent Filter by name substring + pq thread markers --min-duration 10 Only markers >= 10ms + pq thread markers --has-stack Only markers with stack traces + + Step 6: Drill into specifics + pq marker info m-1234 Full details for a marker (from handles in marker list) + pq marker stack m-1234 Full stack trace at the time of a marker + pq function info f-12 Function details (callers/callees, source location) + pq function expand f-12 Show full untruncated function name + + +HANDLE SYSTEM + + Handles are short IDs shown in command output that reference specific items: + t-0, t-1 Thread handles (from "profile info") + m-1234 Marker handles (from "thread markers") + f-12 Function handles (from "thread samples", "thread functions") + ts-6 Timestamp handles (named points in time, usable with "zoom push") + + Handles are stable within a session but may differ across sessions. + + +ZOOM: FOCUS ON A TIME RANGE + + Zoom restricts all subsequent queries to a specific time window. Useful for + drilling into a specific jank period, page load phase, or marker duration. + + pq zoom push 2.7,3.1 Zoom to 2.7s-3.1s (relative to profile start) + pq zoom push m-158 Zoom to the time range of marker m-158 + pq zoom push ts-6,ts-12 Zoom to the range between two named timestamps + pq zoom pop Undo the last zoom + pq zoom clear Return to full profile view + pq status Show current thread selection and active zoom ranges + + After zooming, all thread/marker/sample commands automatically apply the filter. + + +JSON OUTPUT + + Add --json to any command to get structured JSON output, useful for piping to jq: + + pq thread samples --json | jq '.rows[0]' + pq thread markers --json | jq '[.rows[] | select(.totalDuration > 50)]' + pq profile info --json | jq '.threads | length' + + +COMMON ANALYSIS PATTERNS + + Find what's consuming CPU: + pq thread info Pick the main thread (usually GeckoMain) + pq thread select t-0 + pq thread samples Top hot functions by self time + pq thread samples-top-down See the full call tree + + Investigate a jank/slow period: + pq thread markers --min-duration 50 Find long-running markers (>50ms = jank) + pq zoom push m-158 Zoom into that marker's time range + pq thread samples What was executing during that period? + pq zoom pop Back to full profile + + Find slow layout or script execution: + pq thread markers --category Layout --min-duration 5 + pq thread markers --category JavaScript --min-duration 10 + pq thread markers --search Reflow --min-duration 5 + + Deep dive on a specific function: + pq thread samples Find hot function, note its handle (f-12) + pq function info f-12 See callers, callees, source location + pq function expand f-12 See full name if truncated + + Group markers by event type: + pq thread markers --search DOMEvent --group-by field:eventType + pq thread markers --auto-group + pq thread markers --group-by type,name + + Analyze network activity: + pq thread markers --category Network + pq thread markers --category Network --min-duration 100 + pq thread markers --search "http" --category Network + + +UNDERSTANDING THE OUTPUT + + Self time vs total time: + Self time Samples where this function was the innermost frame (actually executing) + Total time All samples where this function appeared anywhere in the stack + + Self time is more actionable -- it pinpoints where CPU time is actually spent. + + Samples: + Profiles are sampled at regular intervals (typically 1ms). Each sample is a snapshot + of the call stack. Higher sample counts = more time spent. Counts are relative + within a profile. + + Call tree views: + samples-top-down Root frames at top, drilling down to leaves. + Use to understand the calling structure and where time originates. + samples-bottom-up Hot leaf functions at top with their callers. + Use to understand what calls a frequently-seen function. + + Scoring strategies (controls node ranking when tree is truncated with --max-lines): + exponential-0.9 (default) Balances depth vs breadth + percentage-only Rank purely by CPU percentage (flatter output) + harmonic-1.0 Emphasizes deeper paths in the tree + + +TIPS + + - Start with "profile info" to understand what's in the profile + - Select a thread before querying -- GeckoMain is usually the main thread + - Self time is more actionable: focus on high self-time functions for optimization + - Idle self time is not a finding on its own -- a longer profile or an inactive thread will + naturally show more idle time. Only investigate idle time if it appears during a period the + thread should be busy (e.g. inside a zoom on a slow operation), which may indicate lock + contention or blocking on another thread. + - Zoom into slow periods: use "zoom push m-" to focus on a slow marker + - Use --json when you need to process or filter results programmatically + - Marker handles are stable: you can refer back to m-1234 multiple times + - JS vs native: JS functions show source locations; native functions show C++/Rust names + - Category filters: use --category with values like Layout, JavaScript, Network, GC, etc. + - Check "status": run "pq status" to confirm selected thread and active zoom ranges + + +SESSION MANAGEMENT + + pq list-sessions List all running daemon sessions + pq stop Stop the current session + pq stop --all Stop all sessions + pq load profile.json.gz --session my-session Named session + pq thread info --session my-session Query a specific session + + +ERROR HANDLING + + "No running session" Run "pq load " first + "Thread not found" Run "pq profile info" to see valid thread handles + "Marker not found" Handles are session-scoped; reload if session restarted diff --git a/src/profile-query-cli/index.ts b/src/profile-query-cli/index.ts index 1e36595e7f..c4583dee6e 100644 --- a/src/profile-query-cli/index.ts +++ b/src/profile-query-cli/index.ts @@ -25,6 +25,7 @@ import * as path from 'path'; import * as os from 'os'; +import guideText from './guide.txt'; import minimist from 'minimist'; import { startDaemon } from './daemon'; import { sendCommand, startNewDaemon, stopDaemon } from './client'; @@ -62,6 +63,7 @@ interface Args { daemon?: boolean; help?: boolean; h?: boolean; + guide?: boolean; json?: boolean; 'max-lines'?: number; scoring?: string; @@ -109,6 +111,7 @@ Options: --scoring Call tree scoring: exponential-0.95, exponential-0.9 (default), exponential-0.8, harmonic-0.1, harmonic-0.5, harmonic-1.0, percentage-only --json Output results as JSON (for use with jq, etc.) + --guide Show detailed usage guide (commands, patterns, tips) --help, -h Show this help message Examples: @@ -151,6 +154,10 @@ Examples: `); } +function printGuide(): void { + console.log(guideText); +} + /** * Format command result for output. * If json flag is set, output JSON. Otherwise output as string. @@ -225,7 +232,16 @@ async function main(): Promise { 'max-lines', 'scoring', ], - boolean: ['daemon', 'help', 'h', 'all', 'has-stack', 'auto-group', 'json'], + boolean: [ + 'daemon', + 'help', + 'h', + 'guide', + 'all', + 'has-stack', + 'auto-group', + 'json', + ], alias: { h: 'help' }, }); @@ -235,6 +251,12 @@ async function main(): Promise { process.exit(0); } + // Check for guide flag + if (argv.guide) { + printGuide(); + process.exit(0); + } + // Internal flag: running as daemon if (argv.daemon) { const profilePath = argv._[0]; diff --git a/src/types/globals/global.d.ts b/src/types/globals/global.d.ts index c9260274e8..969c46442c 100644 --- a/src/types/globals/global.d.ts +++ b/src/types/globals/global.d.ts @@ -26,3 +26,8 @@ declare module '*.png' { const content: string; export default content; } + +declare module '*.txt' { + const content: string; + export default content; +} From 90f64ea97395863ab5b57279543ae49dde559a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 13 Mar 2026 18:04:51 +0100 Subject: [PATCH 07/63] Kill the daemon processes during the test cleanup --- src/profile-query-cli/tests/utils.ts | 34 +++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/profile-query-cli/tests/utils.ts b/src/profile-query-cli/tests/utils.ts index 6f635c8a15..cf778a967d 100644 --- a/src/profile-query-cli/tests/utils.ts +++ b/src/profile-query-cli/tests/utils.ts @@ -3,7 +3,7 @@ */ import { spawn } from 'child_process'; -import { mkdtemp, rm } from 'fs/promises'; +import { mkdtemp, readdir, readFile, rm } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -97,11 +97,43 @@ export async function createTestContext(): Promise { }; } +/** + * Kill all daemon processes tracked in the session directory. + */ +async function killSessionDaemons(sessionDir: string): Promise { + let files: string[]; + try { + files = await readdir(sessionDir); + } catch { + return; + } + + const metadataFiles = files.filter((f) => f.endsWith('.json')); + await Promise.all( + metadataFiles.map(async (file) => { + try { + const content = await readFile(join(sessionDir, file), 'utf-8'); + const metadata = JSON.parse(content) as { pid?: number }; + if (metadata.pid) { + try { + process.kill(metadata.pid, 'SIGTERM'); + } catch { + // Process already gone. + } + } + } catch { + // Ignore unreadable/invalid files. + } + }) + ); +} + /** * Clean up test context. * Each test should call this in afterEach() to remove temp directory. */ export async function cleanupTestContext(ctx: PqTestContext): Promise { + await killSessionDaemons(ctx.sessionDir); await rm(ctx.sessionDir, { recursive: true, force: true }); } From 86be8560e290dd44e15452d58c90c845639eef47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 13 Mar 2026 22:23:21 +0100 Subject: [PATCH 08/63] Implement `pq profile info --all` --- src/profile-query-cli/daemon.ts | 2 +- src/profile-query-cli/formatters.ts | 9 ++++++--- src/profile-query-cli/guide.txt | 4 +++- src/profile-query-cli/index.ts | 5 +++-- src/profile-query-cli/protocol.ts | 2 +- src/profile-query/formatters/profile-info.ts | 6 ++++-- src/profile-query/index.ts | 7 +++++-- src/profile-query/process-thread-list.ts | 17 ++++++++++++++++- src/profile-query/types.ts | 1 + 9 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/profile-query-cli/daemon.ts b/src/profile-query-cli/daemon.ts index acc6f1a156..fddcaa2b70 100644 --- a/src/profile-query-cli/daemon.ts +++ b/src/profile-query-cli/daemon.ts @@ -277,7 +277,7 @@ export class Daemon { case 'profile': switch (command.subcommand) { case 'info': - return this.querier!.profileInfo(); + return this.querier!.profileInfo(command.all); case 'threads': throw new Error('unimplemented'); default: diff --git a/src/profile-query-cli/formatters.ts b/src/profile-query-cli/formatters.ts index 76c693b193..f460d9ecfa 100644 --- a/src/profile-query-cli/formatters.ts +++ b/src/profile-query-cli/formatters.ts @@ -326,7 +326,10 @@ Name: ${result.name}\n`; return output; } - output += '\nTop processes and threads by CPU usage:\n'; + const processesHeading = result.showAll + ? 'All processes and threads by CPU usage:' + : 'Top processes and threads by CPU usage:'; + output += `\n${processesHeading}\n`; for (const process of result.processes) { // Format process timing information @@ -346,12 +349,12 @@ Name: ${result.name}\n`; } if (process.remainingThreads) { - output += ` + ${process.remainingThreads.count} more threads with combined CPU time ${process.remainingThreads.combinedCpuMs.toFixed(3)}ms and max CPU time ${process.remainingThreads.maxCpuMs.toFixed(3)}ms\n`; + output += ` + ${process.remainingThreads.count} more threads with combined CPU time ${process.remainingThreads.combinedCpuMs.toFixed(3)}ms and max CPU time ${process.remainingThreads.maxCpuMs.toFixed(3)}ms (use --all to see all)\n`; } } if (result.remainingProcesses) { - output += ` + ${result.remainingProcesses.count} more processes with combined CPU time ${result.remainingProcesses.combinedCpuMs.toFixed(3)}ms and max CPU time ${result.remainingProcesses.maxCpuMs.toFixed(3)}ms\n`; + output += ` + ${result.remainingProcesses.count} more processes with combined CPU time ${result.remainingProcesses.combinedCpuMs.toFixed(3)}ms and max CPU time ${result.remainingProcesses.maxCpuMs.toFixed(3)}ms (use --all to see all)\n`; } output += '\nCPU activity over time:\n'; diff --git a/src/profile-query-cli/guide.txt b/src/profile-query-cli/guide.txt index 503cd38396..dc8a6aa183 100644 --- a/src/profile-query-cli/guide.txt +++ b/src/profile-query-cli/guide.txt @@ -21,6 +21,7 @@ CORE WORKFLOW Step 2: Explore the profile structure pq profile info Overview: processes, threads, time range, CPU activity + pq profile info --all Show all processes and threads (not just top 5) Step 3: Select a thread to analyze pq thread select t-0 Select a specific thread (persists for future commands) @@ -142,7 +143,8 @@ UNDERSTANDING THE OUTPUT TIPS - - Start with "profile info" to understand what's in the profile + - Start with "profile info" to understand what's in the profile; use --all to see every + process and thread - Select a thread before querying -- GeckoMain is usually the main thread - Self time is more actionable: focus on high self-time functions for optimization - Idle self time is not a finding on its own -- a longer profile or an inactive thread will diff --git a/src/profile-query-cli/index.ts b/src/profile-query-cli/index.ts index c4583dee6e..f97fc7dcd0 100644 --- a/src/profile-query-cli/index.ts +++ b/src/profile-query-cli/index.ts @@ -74,7 +74,7 @@ function printUsage(): void { Commands: load Load a profile and start a daemon session - profile info Print profile summary (processes, threads, CPU activity) + profile info Print profile summary (processes, threads, CPU activity) [--all] thread info Print detailed thread information thread select Select a thread (e.g., t-0, t-1) thread samples Show hot functions list for a thread @@ -103,6 +103,7 @@ Options: --min-duration Filter markers by minimum duration in milliseconds --max-duration Filter markers by maximum duration in milliseconds --min-self Filter functions by minimum self time percentage (for 'thread functions') + --all Show all processes and threads (for 'profile info', overrides default top-5 limit) --has-stack Filter to show only markers with stack traces --limit Limit the number of results shown --group-by Group markers by custom keys (e.g., "type,name" or "type,field:eventType") @@ -307,7 +308,7 @@ async function main(): Promise { if (subcommand === 'info' || subcommand === 'threads') { const result = await sendCommand( SESSION_DIR, - { command: 'profile', subcommand }, + { command: 'profile', subcommand, all: argv.all }, argv.session ); console.log(formatOutput(result, argv.json || false)); diff --git a/src/profile-query-cli/protocol.ts b/src/profile-query-cli/protocol.ts index cdf6e21c0b..fcc3773063 100644 --- a/src/profile-query-cli/protocol.ts +++ b/src/profile-query-cli/protocol.ts @@ -58,7 +58,7 @@ export type ClientMessage = | { type: 'status' }; export type ClientCommand = - | { command: 'profile'; subcommand: 'info' | 'threads' } + | { command: 'profile'; subcommand: 'info' | 'threads'; all?: boolean } | { command: 'thread'; subcommand: diff --git a/src/profile-query/formatters/profile-info.ts b/src/profile-query/formatters/profile-info.ts index 3dc55cc475..4ed97d76ee 100644 --- a/src/profile-query/formatters/profile-info.ts +++ b/src/profile-query/formatters/profile-info.ts @@ -23,7 +23,8 @@ export function collectProfileInfo( store: Store, timestampManager: TimestampManager, threadMap: ThreadMap, - processIndexMap: Map + processIndexMap: Map, + showAll: boolean = false ): ProfileInfoResult { const state = store.getState(); const profile = getProfile(state); @@ -40,7 +41,7 @@ export function collectProfileInfo( })); // Build the process/thread list - const result = buildProcessThreadList(threads, processIndexMap); + const result = buildProcessThreadList(threads, processIndexMap, showAll); // Apply process names and timing from the profile const processesData: ProfileInfoResult['processes'] = result.processes.map( @@ -107,6 +108,7 @@ export function collectProfileInfo( platform: profile.meta.oscpu || 'Unknown', threadCount: profile.threads.length, processCount, + showAll, processes: processesData, remainingProcesses: result.remainingProcesses, cpuActivity, diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index b680cbf15c..12db6f1aa7 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -105,12 +105,15 @@ export class ProfileQuerier { return new ProfileQuerier(store, rootRange); } - async profileInfo(): Promise> { + async profileInfo( + showAll: boolean = false + ): Promise> { const result = await collectProfileInfo( this._store, this._timestampManager, this._threadMap, - this._processIndexMap + this._processIndexMap, + showAll ); return { ...result, context: this._getContext() }; } diff --git a/src/profile-query/process-thread-list.ts b/src/profile-query/process-thread-list.ts index ca8fd9a6d3..6f9a8b6937 100644 --- a/src/profile-query/process-thread-list.ts +++ b/src/profile-query/process-thread-list.ts @@ -55,7 +55,8 @@ export type ProcessThreadListResult = { */ export function buildProcessThreadList( threads: ThreadInfo[], - processIndexMap: Map + processIndexMap: Map, + showAll: boolean = false ): ProcessThreadListResult { // Aggregate threads by process const processCPUMap = new Map(); @@ -93,6 +94,20 @@ export function buildProcessThreadList( const allProcesses = Array.from(processCPUMap.values()); allProcesses.sort((a, b) => b.cpuMs - a.cpuMs); + if (showAll) { + return { + processes: allProcesses.map( + ({ pid, processIndex, name, cpuMs, threads: allThreads }) => ({ + processIndex, + pid, + name, + cpuMs, + threads: allThreads, + }) + ), + }; + } + // Get top 5 processes by CPU const top5ProcessPids = new Set(allProcesses.slice(0, 5).map((p) => p.pid)); diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 0b2863ec98..833d238c45 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -389,6 +389,7 @@ export type ProfileInfoResult = { platform: string; threadCount: number; processCount: number; + showAll?: boolean; processes: Array<{ processIndex: number; pid: string; From 263e2b800bb860e4f99065f5218b194fb1e60f4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 13 Mar 2026 22:39:09 +0100 Subject: [PATCH 09/63] Implement `pq profile info --search` --- src/profile-query-cli/daemon.ts | 2 +- src/profile-query-cli/formatters.ts | 13 +- src/profile-query-cli/guide.txt | 3 +- src/profile-query-cli/index.ts | 9 +- src/profile-query-cli/protocol.ts | 7 +- src/profile-query/formatters/profile-info.ts | 117 +++++++++++++----- src/profile-query/index.ts | 6 +- src/profile-query/process-thread-list.ts | 21 +++- src/profile-query/types.ts | 2 + .../profile-query/process-thread-list.test.ts | 2 +- 10 files changed, 132 insertions(+), 50 deletions(-) diff --git a/src/profile-query-cli/daemon.ts b/src/profile-query-cli/daemon.ts index fddcaa2b70..a39844e965 100644 --- a/src/profile-query-cli/daemon.ts +++ b/src/profile-query-cli/daemon.ts @@ -277,7 +277,7 @@ export class Daemon { case 'profile': switch (command.subcommand) { case 'info': - return this.querier!.profileInfo(command.all); + return this.querier!.profileInfo(command.all, command.search); case 'threads': throw new Error('unimplemented'); default: diff --git a/src/profile-query-cli/formatters.ts b/src/profile-query-cli/formatters.ts index f460d9ecfa..34284e80cb 100644 --- a/src/profile-query-cli/formatters.ts +++ b/src/profile-query-cli/formatters.ts @@ -326,9 +326,14 @@ Name: ${result.name}\n`; return output; } - const processesHeading = result.showAll - ? 'All processes and threads by CPU usage:' - : 'Top processes and threads by CPU usage:'; + let processesHeading: string; + if (result.searchQuery !== undefined) { + processesHeading = `Processes and threads matching '${result.searchQuery}':`; + } else if (result.showAll) { + processesHeading = 'All processes and threads by CPU usage:'; + } else { + processesHeading = 'Top processes and threads by CPU usage:'; + } output += `\n${processesHeading}\n`; for (const process of result.processes) { @@ -345,7 +350,7 @@ Name: ${result.name}\n`; output += ` p-${process.processIndex}: ${process.name} [pid ${process.pid}]${timingInfo} - ${process.cpuMs.toFixed(3)}ms\n`; for (const thread of process.threads) { - output += ` ${thread.threadHandle}: ${thread.name} - ${thread.cpuMs.toFixed(3)}ms\n`; + output += ` ${thread.threadHandle}: ${thread.name} [tid ${thread.tid}] - ${thread.cpuMs.toFixed(3)}ms\n`; } if (process.remainingThreads) { diff --git a/src/profile-query-cli/guide.txt b/src/profile-query-cli/guide.txt index dc8a6aa183..15d01d8f42 100644 --- a/src/profile-query-cli/guide.txt +++ b/src/profile-query-cli/guide.txt @@ -22,6 +22,7 @@ CORE WORKFLOW Step 2: Explore the profile structure pq profile info Overview: processes, threads, time range, CPU activity pq profile info --all Show all processes and threads (not just top 5) + pq profile info --search GeckoMain Filter by process/thread name, pid, or tid Step 3: Select a thread to analyze pq thread select t-0 Select a specific thread (persists for future commands) @@ -144,7 +145,7 @@ UNDERSTANDING THE OUTPUT TIPS - Start with "profile info" to understand what's in the profile; use --all to see every - process and thread + process and thread, or --search to filter by name, pid, or tid - Select a thread before querying -- GeckoMain is usually the main thread - Self time is more actionable: focus on high self-time functions for optimization - Idle self time is not a finding on its own -- a longer profile or an inactive thread will diff --git a/src/profile-query-cli/index.ts b/src/profile-query-cli/index.ts index f97fc7dcd0..3376c26dff 100644 --- a/src/profile-query-cli/index.ts +++ b/src/profile-query-cli/index.ts @@ -74,7 +74,7 @@ function printUsage(): void { Commands: load Load a profile and start a daemon session - profile info Print profile summary (processes, threads, CPU activity) [--all] + profile info Print profile summary (processes, threads, CPU activity) [--all] [--search ] thread info Print detailed thread information thread select Select a thread (e.g., t-0, t-1) thread samples Show hot functions list for a thread @@ -308,7 +308,12 @@ async function main(): Promise { if (subcommand === 'info' || subcommand === 'threads') { const result = await sendCommand( SESSION_DIR, - { command: 'profile', subcommand, all: argv.all }, + { + command: 'profile', + subcommand, + all: argv.all, + search: argv.search, + }, argv.session ); console.log(formatOutput(result, argv.json || false)); diff --git a/src/profile-query-cli/protocol.ts b/src/profile-query-cli/protocol.ts index fcc3773063..9291583a60 100644 --- a/src/profile-query-cli/protocol.ts +++ b/src/profile-query-cli/protocol.ts @@ -58,7 +58,12 @@ export type ClientMessage = | { type: 'status' }; export type ClientCommand = - | { command: 'profile'; subcommand: 'info' | 'threads'; all?: boolean } + | { + command: 'profile'; + subcommand: 'info' | 'threads'; + all?: boolean; + search?: string; + } | { command: 'thread'; subcommand: diff --git a/src/profile-query/formatters/profile-info.ts b/src/profile-query/formatters/profile-info.ts index 4ed97d76ee..276552ea36 100644 --- a/src/profile-query/formatters/profile-info.ts +++ b/src/profile-query/formatters/profile-info.ts @@ -11,11 +11,48 @@ import { getProfileNameWithDefault } from 'firefox-profiler/selectors/url-state' import { buildProcessThreadList } from '../process-thread-list'; import { collectSliceTree } from '../cpu-activity'; import type { Store } from '../../types/store'; -import type { ThreadInfo } from '../process-thread-list'; +import type { ThreadInfo, ProcessListItem } from '../process-thread-list'; import type { TimestampManager } from '../timestamps'; import type { ThreadMap } from '../thread-map'; import type { ProfileInfoResult } from '../types'; +/** + * Filter a list of processes by a search string. + * A process is included if its name or pid matches. + * A thread is included if its name or tid matches, or if its parent process matches. + */ +function applySearchFilter( + processes: ProcessListItem[], + search: string +): ProcessListItem[] { + const query = search.toLowerCase(); + const result: ProcessListItem[] = []; + + for (const process of processes) { + const processMatches = + process.name.toLowerCase().includes(query) || + String(process.pid).includes(query); + + const matchingThreads = processMatches + ? process.threads + : process.threads.filter( + (t) => + t.name.toLowerCase().includes(query) || + String(t.tid).includes(query) + ); + + if (matchingThreads.length > 0) { + result.push({ + ...process, + threads: matchingThreads, + remainingThreads: undefined, + }); + } + } + + return result; +} + /** * Collect profile information in structured format. */ @@ -24,7 +61,8 @@ export function collectProfileInfo( timestampManager: TimestampManager, threadMap: ThreadMap, processIndexMap: Map, - showAll: boolean = false + showAll: boolean = false, + search?: string ): ProfileInfoResult { const state = store.getState(); const profile = getProfile(state); @@ -36,57 +74,68 @@ export function collectProfileInfo( const threads: ThreadInfo[] = profile.threads.map((thread, index) => ({ threadIndex: index, name: thread.name, + tid: thread.tid, cpuMs: threadCPUTimeMs ? threadCPUTimeMs[index] : 0, pid: thread.pid, })); - // Build the process/thread list - const result = buildProcessThreadList(threads, processIndexMap, showAll); + // Build the process/thread list (always show all when searching) + const result = buildProcessThreadList( + threads, + processIndexMap, + showAll || search !== undefined + ); // Apply process names and timing from the profile - const processesData: ProfileInfoResult['processes'] = result.processes.map( - (processItem) => { - // Find a thread from this process to get the process name and timing - const threadFromProcess = profile.threads.find( - (t) => t.pid === processItem.pid - ); + result.processes.forEach((processItem) => { + const threadFromProcess = profile.threads.find( + (t) => t.pid === processItem.pid + ); + if (threadFromProcess) { + processItem.name = + threadFromProcess.processName || + threadFromProcess.processType || + 'unknown'; + processItem.startTime = threadFromProcess.processStartupTime; + processItem.endTime = threadFromProcess.processShutdownTime; + } + }); - let processName = 'unknown'; - let startTime: number | undefined; + // Apply search filter after process names are resolved + const processesToShow = + search !== undefined + ? applySearchFilter(result.processes, search) + : result.processes; + + const processesData: ProfileInfoResult['processes'] = processesToShow.map( + (processItem) => { let startTimeName: string | undefined; - let endTime: number | null | undefined; let endTimeName: string | null | undefined; - - if (threadFromProcess) { - processName = - threadFromProcess.processName || - threadFromProcess.processType || - 'unknown'; - startTime = threadFromProcess.processStartupTime; - if (startTime !== undefined) { - startTimeName = timestampManager.nameForTimestamp(startTime); - endTime = threadFromProcess.processShutdownTime; - if (endTime !== null && endTime !== undefined) { - endTimeName = timestampManager.nameForTimestamp(endTime); - } else { - endTimeName = null; - } + if (processItem.startTime !== undefined) { + startTimeName = timestampManager.nameForTimestamp( + processItem.startTime + ); + if (processItem.endTime !== null && processItem.endTime !== undefined) { + endTimeName = timestampManager.nameForTimestamp(processItem.endTime); + } else { + endTimeName = null; } } return { processIndex: processItem.processIndex, pid: processItem.pid, - name: processName, + name: processItem.name, cpuMs: processItem.cpuMs, - startTime, + startTime: processItem.startTime, startTimeName, - endTime, + endTime: processItem.endTime, endTimeName, threads: processItem.threads.map((thread) => ({ threadIndex: thread.threadIndex, threadHandle: threadMap.handleForThreadIndex(thread.threadIndex), name: thread.name, + tid: thread.tid, cpuMs: thread.cpuMs, })), remainingThreads: processItem.remainingThreads, @@ -108,9 +157,11 @@ export function collectProfileInfo( platform: profile.meta.oscpu || 'Unknown', threadCount: profile.threads.length, processCount, - showAll, + showAll: showAll && search === undefined, + searchQuery: search, processes: processesData, - remainingProcesses: result.remainingProcesses, + remainingProcesses: + search !== undefined ? undefined : result.remainingProcesses, cpuActivity, }; } diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 12db6f1aa7..9fc5359f5e 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -106,14 +106,16 @@ export class ProfileQuerier { } async profileInfo( - showAll: boolean = false + showAll: boolean = false, + search?: string ): Promise> { const result = await collectProfileInfo( this._store, this._timestampManager, this._threadMap, this._processIndexMap, - showAll + showAll, + search ); return { ...result, context: this._getContext() }; } diff --git a/src/profile-query/process-thread-list.ts b/src/profile-query/process-thread-list.ts index 6f9a8b6937..e5830b5617 100644 --- a/src/profile-query/process-thread-list.ts +++ b/src/profile-query/process-thread-list.ts @@ -5,6 +5,7 @@ export type ThreadInfo = { threadIndex: number; name: string; + tid: number | string; cpuMs: number; pid: string; }; @@ -14,7 +15,12 @@ export type ProcessInfo = { processIndex: number; name: string; cpuMs: number; - threads: Array<{ threadIndex: number; name: string; cpuMs: number }>; + threads: Array<{ + threadIndex: number; + name: string; + tid: number | string; + cpuMs: number; + }>; }; export type ProcessListItem = { @@ -22,7 +28,12 @@ export type ProcessListItem = { pid: string; name: string; cpuMs: number; - threads: Array<{ threadIndex: number; name: string; cpuMs: number }>; + threads: Array<{ + threadIndex: number; + name: string; + tid: number | string; + cpuMs: number; + }>; remainingThreads?: { count: number; combinedCpuMs: number; @@ -62,12 +73,12 @@ export function buildProcessThreadList( const processCPUMap = new Map(); threads.forEach((thread) => { - const { pid, threadIndex, name, cpuMs } = thread; + const { pid, threadIndex, name, tid, cpuMs } = thread; const existing = processCPUMap.get(pid); if (existing) { existing.cpuMs += cpuMs; - existing.threads.push({ threadIndex, name, cpuMs }); + existing.threads.push({ threadIndex, name, tid, cpuMs }); } else { const processIndex = processIndexMap.get(pid); if (processIndex === undefined) { @@ -80,7 +91,7 @@ export function buildProcessThreadList( processIndex, name: pid, // Will be overridden by caller cpuMs, - threads: [{ threadIndex, name, cpuMs }], + threads: [{ threadIndex, name, tid, cpuMs }], }); } }); diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 833d238c45..0f9bd6ec7c 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -390,6 +390,7 @@ export type ProfileInfoResult = { threadCount: number; processCount: number; showAll?: boolean; + searchQuery?: string; processes: Array<{ processIndex: number; pid: string; @@ -403,6 +404,7 @@ export type ProfileInfoResult = { threadIndex: number; threadHandle: string; name: string; + tid: number | string; cpuMs: number; }>; remainingThreads?: { diff --git a/src/test/unit/profile-query/process-thread-list.test.ts b/src/test/unit/profile-query/process-thread-list.test.ts index f394e4fe7d..08020b444c 100644 --- a/src/test/unit/profile-query/process-thread-list.test.ts +++ b/src/test/unit/profile-query/process-thread-list.test.ts @@ -13,7 +13,7 @@ describe('buildProcessThreadList', function () { name: string, cpuMs: number ): ThreadInfo { - return { threadIndex, pid, name, cpuMs }; + return { threadIndex, pid, name, tid: threadIndex, cpuMs }; } it('shows top 5 processes by CPU, plus any needed for top 20 threads', function () { From 7b32fb38fd0b675dc8e9aad7bdd2357db2ca1a97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 13 Mar 2026 23:44:02 +0100 Subject: [PATCH 10/63] Add tid to the thread info --- src/profile-query-cli/formatters.ts | 1 + src/profile-query/formatters/thread-info.ts | 2 ++ src/profile-query/types.ts | 1 + 3 files changed, 4 insertions(+) diff --git a/src/profile-query-cli/formatters.ts b/src/profile-query-cli/formatters.ts index 34284e80cb..c5d2a00f78 100644 --- a/src/profile-query-cli/formatters.ts +++ b/src/profile-query-cli/formatters.ts @@ -181,6 +181,7 @@ export function formatThreadInfoResult( let output = `${contextHeader} Name: ${result.friendlyName} +TID: ${result.tid} Created at: ${result.createdAtName} Ended at: ${endedAtStr} diff --git a/src/profile-query/formatters/thread-info.ts b/src/profile-query/formatters/thread-info.ts index 6b998f6a45..44b8742687 100644 --- a/src/profile-query/formatters/thread-info.ts +++ b/src/profile-query/formatters/thread-info.ts @@ -60,6 +60,7 @@ export function formatThreadInfo( return `\ Name: ${friendlyThreadName} +TID: ${thread.tid} Created at: ${timestampManager.nameForTimestamp(thread.registerTime)} Ended at: ${thread.unregisterTime !== null ? timestampManager.nameForTimestamp(thread.unregisterTime) : 'still alive at end of recording'} @@ -102,6 +103,7 @@ export function collectThreadInfo( threadHandle: actualThreadHandle, name: thread.name, friendlyName: friendlyThreadName, + tid: thread.tid, createdAt: thread.registerTime, createdAtName: timestampManager.nameForTimestamp(thread.registerTime), endedAt: thread.unregisterTime, diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 0f9bd6ec7c..323ad0e797 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -155,6 +155,7 @@ export type ThreadInfoResult = { threadHandle: string; name: string; friendlyName: string; + tid: number | string; createdAt: number; createdAtName: string; endedAt: number | null; From 232eabcc7c2605cfd353e59b2e56ed55806931cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 16 Mar 2026 12:20:54 +0100 Subject: [PATCH 11/63] Make sure to mention that step 2 is required in the guide. `pq thread select t-x` doesn't work without running `pq profile info` first. If the pq session is restarted in between, we need to call it again to make sure that we generate the thread handles etc. --- src/profile-query-cli/guide.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/profile-query-cli/guide.txt b/src/profile-query-cli/guide.txt index 15d01d8f42..3ab82018da 100644 --- a/src/profile-query-cli/guide.txt +++ b/src/profile-query-cli/guide.txt @@ -24,7 +24,7 @@ CORE WORKFLOW pq profile info --all Show all processes and threads (not just top 5) pq profile info --search GeckoMain Filter by process/thread name, pid, or tid - Step 3: Select a thread to analyze + Step 3: Select a thread to analyze (requires Step 2 -- profile info builds thread handles) pq thread select t-0 Select a specific thread (persists for future commands) Step 4: Analyze CPU usage From 14b9087d5481126e7bb9568859ba83bbb3d1d69c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 16 Mar 2026 22:36:17 +0100 Subject: [PATCH 12/63] Show time values relative to the root in `pq marker info` --- src/profile-query-cli/formatters.ts | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/profile-query-cli/formatters.ts b/src/profile-query-cli/formatters.ts index c5d2a00f78..e851d426dd 100644 --- a/src/profile-query-cli/formatters.ts +++ b/src/profile-query-cli/formatters.ts @@ -255,22 +255,15 @@ Marker ${result.markerHandle}: ${result.name}`; output += `Type: ${result.markerType ?? 'None'}\n`; output += `Category: ${result.category.name}\n`; - // Time and duration - const startStr = result.start.toFixed(3); + // Time and duration (relative to profile root start) + const rootStart = result.context.rootRange.start; + const startStr = formatDuration(result.start - rootStart); if (result.end !== null) { - const endStr = result.end.toFixed(3); - const durationMs = result.duration!; - let durationStr: string; - if (durationMs < 1) { - durationStr = `${(durationMs * 1000).toFixed(1)}µs`; - } else if (durationMs < 1000) { - durationStr = `${durationMs.toFixed(2)}ms`; - } else { - durationStr = `${(durationMs / 1000).toFixed(3)}s`; - } - output += `Time: ${startStr}ms - ${endStr}ms (${durationStr})\n`; + const endStr = formatDuration(result.end - rootStart); + const durationStr = formatDuration(result.duration!); + output += `Time: ${startStr} - ${endStr} (${durationStr})\n`; } else { - output += `Time: ${startStr}ms (instant)\n`; + output += `Time: ${startStr} (instant)\n`; } output += `Thread: ${result.threadHandle} (${result.friendlyThreadName})\n`; @@ -293,7 +286,7 @@ Marker ${result.markerHandle}: ${result.name}`; if (result.stack && result.stack.frames.length > 0) { output += '\nStack trace:\n'; if (result.stack.capturedAt !== undefined) { - output += ` Captured at: ${result.stack.capturedAt.toFixed(3)}ms\n`; + output += ` Captured at: ${formatDuration(result.stack.capturedAt - rootStart)}\n`; } for (let i = 0; i < result.stack.frames.length; i++) { From 97d90198c1309f79c783ab460d5be49e63c89d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 16 Mar 2026 22:47:17 +0100 Subject: [PATCH 13/63] Show relative timestamp value in stack information too --- src/profile-query-cli/formatters.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/profile-query-cli/formatters.ts b/src/profile-query-cli/formatters.ts index e851d426dd..e1a847f5b0 100644 --- a/src/profile-query-cli/formatters.ts +++ b/src/profile-query-cli/formatters.ts @@ -221,7 +221,8 @@ Stack trace for marker ${result.markerHandle}: ${result.markerName}\n`; } if (result.stack.capturedAt !== undefined) { - output += `\nCaptured at: ${result.stack.capturedAt.toFixed(3)}ms\n`; + const rootStart = result.context.rootRange.start; + output += `\nCaptured at: ${formatDuration(result.stack.capturedAt - rootStart)}\n`; } for (let i = 0; i < result.stack.frames.length; i++) { From c81589741d55f1b127171069fb74ae10380c5f56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 16 Mar 2026 23:28:36 +0100 Subject: [PATCH 14/63] Experimental Windows support --- src/profile-query-cli/README.md | 12 +++++++----- src/profile-query-cli/client.ts | 6 +++++- src/profile-query-cli/daemon.ts | 6 +++--- src/profile-query-cli/session.ts | 12 ++++++++---- src/profile-query-cli/tests/daemon-startup.test.ts | 8 ++++++-- 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/profile-query-cli/README.md b/src/profile-query-cli/README.md index a4dd881a1b..b4c1ff9fc3 100644 --- a/src/profile-query-cli/README.md +++ b/src/profile-query-cli/README.md @@ -9,7 +9,7 @@ A command-line interface for querying Firefox Profiler profiles with persistent - **Daemon process**: Long-running background process that loads a profile via `ProfileQuerier` and keeps it in memory - **Client process**: Short-lived process that sends commands to the daemon and prints results -**IPC:** Unix domain sockets with line-delimited JSON messages +**IPC:** Unix domain sockets (named pipes on Windows) with line-delimited JSON messages **Session storage:** `~/.pq/` (or `$PQ_SESSION_DIR` for development) @@ -94,17 +94,19 @@ npm publish ``` ~/.pq/ ├── current # Symlink to current session socket -├── .sock # Unix domain socket for IPC +├── .sock # Unix domain socket for IPC (Unix only) ├── .json # Session metadata (PID, profile path, timestamps) └── .log # Daemon logs (kept for debugging) ``` +On Windows, IPC uses a named pipe (`\\.\pipe\pq-`) instead of a `.sock` file. + **Session metadata example:** ```json { "id": "abc123xyz", - "socketPath": "/Users/user/.pq/abc123xyz.sock", + "socketPath": "/Users/user/.pq/abc123xyz.sock", // or \\.\pipe\pq-abc123xyz on Windows "logPath": "/Users/user/.pq/abc123xyz.log", "pid": 12345, "profilePath": "/path/to/profile.json", @@ -181,7 +183,7 @@ type ServerResponse = **Session validation (session.ts):** - Check PID is running (`process.kill(pid, 0)`) -- Check socket file exists +- Check socket file exists (Unix only — named pipes on Windows are not filesystem files) - Auto-cleanup stale sessions **Symlinks:** @@ -194,7 +196,7 @@ type ServerResponse = **Implemented:** - ✅ Persistent daemon with profile loading -- ✅ Unix socket IPC +- ✅ Unix socket IPC (named pipes on Windows) - ✅ Multiple concurrent sessions - ✅ Session management (current session, explicit session IDs) - ✅ Environment variable isolation (`PQ_SESSION_DIR`) diff --git a/src/profile-query-cli/client.ts b/src/profile-query-cli/client.ts index c1a5614725..ac985fec4f 100644 --- a/src/profile-query-cli/client.ts +++ b/src/profile-query-cli/client.ts @@ -58,7 +58,11 @@ async function sendRawMessage( ? getSocketPath(sessionDir, sessionId) : getCurrentSocketPath(sessionDir); - if (!socketPath || !fs.existsSync(socketPath)) { + // On Windows, named pipes are not filesystem files so existsSync always returns false + if ( + !socketPath || + (process.platform !== 'win32' && !fs.existsSync(socketPath)) + ) { throw new Error(`Socket not found for session ${resolvedSessionId}`); } diff --git a/src/profile-query-cli/daemon.ts b/src/profile-query-cli/daemon.ts index a39844e965..c60990b063 100644 --- a/src/profile-query-cli/daemon.ts +++ b/src/profile-query-cli/daemon.ts @@ -1,6 +1,6 @@ /** * Daemon process for pq. - * Loads a profile and listens for commands on a Unix socket. + * Loads a profile and listens for commands on a Unix socket (or named pipe on Windows). */ import * as net from 'net'; @@ -90,8 +90,8 @@ export class Daemon { // Create Unix socket server BEFORE loading the profile this.server = net.createServer((socket) => this.handleConnection(socket)); - // Remove stale socket if it exists - if (fs.existsSync(this.socketPath)) { + // Remove stale socket if it exists (Unix only — named pipes on Windows are not filesystem files) + if (process.platform !== 'win32' && fs.existsSync(this.socketPath)) { fs.unlinkSync(this.socketPath); } diff --git a/src/profile-query-cli/session.ts b/src/profile-query-cli/session.ts index a0a14c950c..a02c48a277 100644 --- a/src/profile-query-cli/session.ts +++ b/src/profile-query-cli/session.ts @@ -29,8 +29,12 @@ export function generateSessionId(): string { /** * Get the socket path for a session. + * On Windows, returns a named pipe path. On Unix, returns a .sock file path. */ export function getSocketPath(sessionDir: string, sessionId: string): string { + if (process.platform === 'win32') { + return `\\\\.\\pipe\\pq-${sessionId}`; + } return path.join(sessionDir, `${sessionId}.sock`); } @@ -143,8 +147,8 @@ export function cleanupSession(sessionDir: string, sessionId: string): void { // Note: We intentionally don't delete the log file for debugging purposes // const logPath = getLogPath(sessionDir, sessionId); - // Remove socket file - if (fs.existsSync(socketPath)) { + // Remove socket file (Unix only — named pipes on Windows are not filesystem files) + if (process.platform !== 'win32' && fs.existsSync(socketPath)) { fs.unlinkSync(socketPath); } @@ -181,8 +185,8 @@ export function validateSession( return null; } - // Check if socket exists - if (!fs.existsSync(metadata.socketPath)) { + // Check if socket exists (Unix only — named pipes on Windows are not filesystem files) + if (process.platform !== 'win32' && !fs.existsSync(metadata.socketPath)) { // console.error(`Session ${sessionId} socket not found. Cleaning up.`); return null; } diff --git a/src/profile-query-cli/tests/daemon-startup.test.ts b/src/profile-query-cli/tests/daemon-startup.test.ts index 8203874dd4..676c85dea5 100644 --- a/src/profile-query-cli/tests/daemon-startup.test.ts +++ b/src/profile-query-cli/tests/daemon-startup.test.ts @@ -12,6 +12,7 @@ import { pqFail, type PqTestContext, } from './utils'; +import { getSocketPath } from '../session'; describe('daemon startup (two-phase)', () => { let ctx: PqTestContext; @@ -102,10 +103,13 @@ describe('daemon startup (two-phase)', () => { const sessionId = match![1]; // Verify both socket and metadata exist (validateSession checks both) - const socketPath = join(ctx.sessionDir, `${sessionId}.sock`); + const socketPath = getSocketPath(ctx.sessionDir, sessionId); const metadataPath = join(ctx.sessionDir, `${sessionId}.json`); - await expect(access(socketPath)).resolves.toBeUndefined(); + // Named pipes on Windows are not filesystem files, skip the access check there + if (process.platform !== 'win32') { + await expect(access(socketPath)).resolves.toBeUndefined(); + } await expect(access(metadataPath)).resolves.toBeUndefined(); // Process should be running (metadata contains PID) From a1e31cfbbe3f078e7c962e5c682787af269c06f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Thu, 9 Apr 2026 21:53:53 +0200 Subject: [PATCH 15/63] Change the pq directory structure --- .prettierignore | 2 +- eslint.config.mjs | 2 +- jest.config.js | 98 +++++++++++-------- package.json | 2 +- .../.npmignore | 0 .../README.md | 45 +++++---- .../guide.txt | 3 +- .../package.json | 10 +- .../src}/client.ts | 4 + profile-query-cli/src/constants.ts | 16 +++ .../src}/daemon.ts | 10 +- .../src}/formatters.ts | 6 +- .../src}/index.ts | 8 +- .../src}/protocol.ts | 12 ++- .../src}/session.ts | 4 + .../src/test/integration}/basic.test.ts | 4 + .../test/integration}/daemon-startup.test.ts | 6 +- .../src/test/integration}/sessions.test.ts | 4 + .../src/test/integration}/setup.ts | 4 + .../src/test/integration}/utils.ts | 6 +- .../call-tree-formatting.test.ts.snap | 0 .../test/unit}/call-tree-formatting.test.ts | 14 +-- .../src/test/unit}/client.test.ts | 0 .../src/test/unit}/daemon.test.ts | 0 .../src/test/unit}/session.test.ts | 4 +- scripts/build-profile-query-cli.mjs | 8 +- src/profile-query-cli/constants.ts | 12 --- tsconfig.json | 7 +- 28 files changed, 183 insertions(+), 108 deletions(-) rename {src/profile-query-cli => profile-query-cli}/.npmignore (100%) rename {src/profile-query-cli => profile-query-cli}/README.md (85%) rename {src/profile-query-cli => profile-query-cli}/guide.txt (98%) rename {src/profile-query-cli => profile-query-cli}/package.json (85%) rename {src/profile-query-cli => profile-query-cli/src}/client.ts (97%) create mode 100644 profile-query-cli/src/constants.ts rename {src/profile-query-cli => profile-query-cli/src}/daemon.ts (96%) rename {src/profile-query-cli => profile-query-cli/src}/formatters.ts (99%) rename {src/profile-query-cli => profile-query-cli/src}/index.ts (98%) rename {src/profile-query-cli => profile-query-cli/src}/protocol.ts (86%) rename {src/profile-query-cli => profile-query-cli/src}/session.ts (96%) rename {src/profile-query-cli/tests => profile-query-cli/src/test/integration}/basic.test.ts (93%) rename {src/profile-query-cli/tests => profile-query-cli/src/test/integration}/daemon-startup.test.ts (94%) rename {src/profile-query-cli/tests => profile-query-cli/src/test/integration}/sessions.test.ts (93%) rename {src/profile-query-cli/tests => profile-query-cli/src/test/integration}/setup.ts (51%) rename {src/profile-query-cli/tests => profile-query-cli/src/test/integration}/utils.ts (94%) rename {src/test/unit/profile-query-cli => profile-query-cli/src/test/unit}/__snapshots__/call-tree-formatting.test.ts.snap (100%) rename {src/test/unit/profile-query-cli => profile-query-cli/src/test/unit}/call-tree-formatting.test.ts (97%) rename {src/test/unit/profile-query-cli => profile-query-cli/src/test/unit}/client.test.ts (100%) rename {src/test/unit/profile-query-cli => profile-query-cli/src/test/unit}/daemon.test.ts (100%) rename {src/test/unit/profile-query-cli => profile-query-cli/src/test/unit}/session.test.ts (98%) delete mode 100644 src/profile-query-cli/constants.ts diff --git a/.prettierignore b/.prettierignore index abee240400..a9d956a335 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,6 @@ src/profile-logic/import/proto src/types/libdef/npm -src/profile-query-cli/dist +profile-query-cli/dist docs-user/js docs-user/css src/test/fixtures/upgrades diff --git a/eslint.config.mjs b/eslint.config.mjs index 705e2c0d1a..c2bbe4484c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,7 +18,7 @@ export default defineConfig( ignores: [ 'src/profile-logic/import/proto/**', 'src/types/libdef/npm/**', - 'src/profile-query-cli/dist/**', + 'profile-query-cli/dist/**', 'res/**', 'dist/**', 'docs-user/**', diff --git a/jest.config.js b/jest.config.js index 347b2a15b2..c36d63772f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,77 +2,89 @@ * 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/. */ +// Shared config for projects that need a browser-like (jsdom) environment. +// CLI unit tests use the same environment because they import browser-side +// fixtures to construct profile data. +const browserEnvConfig = { + testEnvironment: './src/test/custom-environment', + setupFilesAfterEnv: ['jest-extended/all', './src/test/setup.ts'], + moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], + resolver: './jest-resolver.js', + + transform: { + '\\.([jt]sx?|mjs)$': 'babel-jest', + }, + + // Transform ESM modules to CommonJS for Jest + // These packages ship as pure ESM and need to be transformed by Babel + transformIgnorePatterns: [ + '/node_modules/(?!(query-string|decode-uri-component|iongraph-web|split-on-first|filter-obj|fetch-mock|devtools-reps)/)', + ], + + // Mock static assets (images, CSS, etc.) + moduleNameMapper: { + '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|ftl)$': + '/src/test/fixtures/mocks/file-mock.ts', + '\\.(css|less)$': '/src/test/fixtures/mocks/style-mock.ts', + }, + + globals: { + AVAILABLE_STAGING_LOCALES: null, + }, + + snapshotFormat: { + escapeString: true, + printBasicPrototype: true, + }, +}; + module.exports = { projects: [ // ======================================================================== // Browser Tests (React/browser environment) // ======================================================================== { + ...browserEnvConfig, displayName: 'browser', testMatch: ['/src/**/*.test.{js,jsx,ts,tsx}'], - testPathIgnorePatterns: ['/src/profile-query-cli/tests/'], - moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], - - // Use custom resolver that respects the "browser" field in package.json - resolver: './jest-resolver.js', - - testEnvironment: './src/test/custom-environment', - setupFilesAfterEnv: ['jest-extended/all', './src/test/setup.ts'], collectCoverageFrom: [ 'src/**/*.{js,jsx,ts,tsx}', '!**/node_modules/**', '!src/types/libdef/**', ], - - transform: { - '\\.([jt]sx?|mjs)$': 'babel-jest', - }, - - // Transform ESM modules to CommonJS for Jest - // These packages ship as pure ESM and need to be transformed by Babel - transformIgnorePatterns: [ - '/node_modules/(?!(query-string|decode-uri-component|iongraph-web|split-on-first|filter-obj|fetch-mock|devtools-reps)/)', - ], - - // Mock static assets (images, CSS, etc.) - moduleNameMapper: { - '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|ftl)$': - '/src/test/fixtures/mocks/file-mock.ts', - '\\.(css|less)$': '/src/test/fixtures/mocks/style-mock.ts', - }, - - globals: { - AVAILABLE_STAGING_LOCALES: null, - }, - - snapshotFormat: { - escapeString: true, - printBasicPrototype: true, - }, }, // ======================================================================== - // CLI Tests (Node.js environment) + // CLI Unit Tests (browser/jsdom environment - imports browser-side fixtures) // ======================================================================== { + ...browserEnvConfig, displayName: 'cli', - testMatch: ['/src/profile-query-cli/tests/**/*.test.ts'], + testMatch: ['/profile-query-cli/src/test/unit/**/*.test.ts'], + }, + + // ======================================================================== + // CLI Integration Tests (Node.js environment - spawns real processes) + // ======================================================================== + { + displayName: 'cli-integration', + testMatch: [ + '/profile-query-cli/src/test/integration/**/*.test.ts', + ], - // Use Node.js environment (not browser/jsdom) testEnvironment: 'node', - // CLI-specific setup (just jest-extended for matchers) - setupFilesAfterEnv: ['./src/profile-query-cli/tests/setup.ts'], + setupFilesAfterEnv: ['./profile-query-cli/src/test/integration/setup.ts'], - // CLI operations can be slow (loading profiles, spawning processes) + // Integration tests can be slow (loading profiles, spawning processes) testTimeout: 30000, - // File extensions for CLI tests moduleFileExtensions: ['ts', 'js'], - // No need for asset mocks in CLI tests - // No transformIgnorePatterns needed - we don't use ESM-only deps here + transform: { + '\\.([jt]sx?|mjs)$': 'babel-jest', + }, }, ], }; diff --git a/package.json b/package.json index a2ab1b6889..b9ce9552ca 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "test-alex": "alex ./docs-* CODE_OF_CONDUCT.md CONTRIBUTING.md README.md", "test-lockfile": "lockfile-lint --path yarn.lock --allowed-hosts yarn --validate-https", "test-debug": "cross-env LC_ALL=C TZ=UTC NODE_ENV=test node --inspect-brk node_modules/.bin/jest --runInBand", - "test:cli": "yarn build-profile-query-cli && jest --selectProjects=cli", + "test-cli": "yarn build-profile-query-cli && node bin/output-fixing-commands.js cross-env LC_ALL=C TZ=UTC NODE_ENV=test jest --selectProjects=cli cli-integration", "postinstall": "patch-package" }, "license": "MPL-2.0", diff --git a/src/profile-query-cli/.npmignore b/profile-query-cli/.npmignore similarity index 100% rename from src/profile-query-cli/.npmignore rename to profile-query-cli/.npmignore diff --git a/src/profile-query-cli/README.md b/profile-query-cli/README.md similarity index 85% rename from src/profile-query-cli/README.md rename to profile-query-cli/README.md index b4c1ff9fc3..1706d757ff 100644 --- a/src/profile-query-cli/README.md +++ b/profile-query-cli/README.md @@ -49,25 +49,28 @@ pq thread info --thread t-0 # View info for specific thread without selecting ## Project Structure ``` -src/profile-query-cli/ -├── index.ts # CLI entry point, argument parsing, command routing -├── client.ts # Client logic: spawn daemon, send commands via socket -├── daemon.ts # Daemon logic: load profile, listen on socket, handle commands -├── session.ts # Session file management, socket paths, validation -├── protocol.ts # TypeScript types for IPC messages -├── webpack.config.js # Build config with shebang and Node.js polyfills -├── package.json # npm distribution metadata (dependencies defined in root) -└── tests/ # CLI integration tests +profile-query-cli/ +├── src/ +│ ├── index.ts # CLI entry point, argument parsing, command routing +│ ├── client.ts # Client logic: spawn daemon, send commands via socket +│ ├── daemon.ts # Daemon logic: load profile, listen on socket, handle commands +│ ├── session.ts # Session file management, socket paths, validation +│ ├── protocol.ts # TypeScript types for IPC messages +│ └── test/ +│ ├── unit/ # CLI unit tests +│ └── integration/ # CLI integration tests +├── package.json # npm distribution metadata (dependencies defined in root) +└── dist/ # Bundled executable output ``` ## Build & Distribution This package uses a **bundled distribution approach**: -- **Source code**: Lives in `src/profile-query-cli/` within the firefox-devtools/profiler monorepo +- **Source code**: Lives in `profile-query-cli/src/` within the firefox-devtools/profiler monorepo - **Dependencies**: Defined in the root `package.json` (react, redux, protobufjs, etc.) -- **Build process**: Webpack bundles and minifies everything into a single ~640KB `dist/pq.js` file (~187KB gzipped) with zero runtime dependencies -- **Published artifact**: Just the `dist/pq.js` executable is published to npm as `@firefox-profiler/pq` +- **Build process**: The CLI build writes a single ~640KB executable to `profile-query-cli/dist/pq.js` (~187KB gzipped) with zero runtime dependencies +- **Published artifact**: `profile-query-cli/dist/pq.js` is published to npm as `@firefox-profiler/pq` - **Package.json**: Contains only npm metadata - it does NOT list dependencies since they're pre-bundled This means: @@ -81,7 +84,7 @@ To publish: ```bash # From repository root yarn build-profile-query-cli -cd src/profile-query-cli +cd profile-query-cli npm publish ``` @@ -128,7 +131,7 @@ All test scripts automatically set `PQ_SESSION_DIR="./.pq-dev"` to avoid polluti **Build:** ```bash -yarn build-profile-query-cli # Creates ./dist/pq.js, global `pq` forwards to this +yarn build-profile-query-cli # Creates ./dist/pq.js ``` **Unit tests:** @@ -140,7 +143,7 @@ yarn test profile-query **CLI integration tests:** ```bash -yarn test:cli +yarn test-cli ``` ## Implementation Details @@ -223,13 +226,13 @@ type ServerResponse = ## Build Configuration -**Key webpack settings:** +**Current build behavior:** -- `target: 'node'` - Node.js output -- `stats: 'errors-warnings'` - Quiet builds -- `BannerPlugin` - Adds `#!/usr/bin/env node` shebang -- `BannerPlugin` - Adds `globalThis.self = globalThis` polyfill for browser globals -- `optimization.minimize: false` - Keep readable stack traces +- esbuild bundles the CLI for Node.js +- A build banner adds the `#!/usr/bin/env node` shebang +- The banner also sets `globalThis.self = globalThis` for browser-oriented shared code +- `__BUILD_HASH__` is injected at build time +- `gecko-profiler-demangle` is left external to keep the CLI lean - Postbuild: `chmod +x dist/pq.js` ## Adding New Commands diff --git a/src/profile-query-cli/guide.txt b/profile-query-cli/guide.txt similarity index 98% rename from src/profile-query-cli/guide.txt rename to profile-query-cli/guide.txt index 3ab82018da..d9049c6863 100644 --- a/src/profile-query-cli/guide.txt +++ b/profile-query-cli/guide.txt @@ -19,7 +19,7 @@ CORE WORKFLOW Step 1: Load a profile pq load profile.json.gz - Step 2: Explore the profile structure + Step 2: Explore the profile pq profile info Overview: processes, threads, time range, CPU activity pq profile info --all Show all processes and threads (not just top 5) pq profile info --search GeckoMain Filter by process/thread name, pid, or tid @@ -71,6 +71,7 @@ ZOOM: FOCUS ON A TIME RANGE pq status Show current thread selection and active zoom ranges After zooming, all thread/marker/sample commands automatically apply the filter. + Use "pq status" to confirm the active zoom stack (along with thread and filters). JSON OUTPUT diff --git a/src/profile-query-cli/package.json b/profile-query-cli/package.json similarity index 85% rename from src/profile-query-cli/package.json rename to profile-query-cli/package.json index dba0aecd0e..aa02a2c47d 100644 --- a/src/profile-query-cli/package.json +++ b/profile-query-cli/package.json @@ -11,7 +11,13 @@ "dist/pq.js" ], "engines": { - "node": ">= 22 < 23" + "node": ">= 24 < 25" + }, + "devEngines": { + "runtime": { + "name": "node", + "version": ">= 24 < 25" + } }, "keywords": [ "profiler", @@ -26,7 +32,7 @@ "repository": { "type": "git", "url": "https://github.com/firefox-devtools/profiler", - "directory": "src/profile-query-cli" + "directory": "profile-query-cli" }, "homepage": "https://profiler.firefox.com", "bugs": { diff --git a/src/profile-query-cli/client.ts b/profile-query-cli/src/client.ts similarity index 97% rename from src/profile-query-cli/client.ts rename to profile-query-cli/src/client.ts index ac985fec4f..e8a4b845f7 100644 --- a/src/profile-query-cli/client.ts +++ b/profile-query-cli/src/client.ts @@ -1,3 +1,7 @@ +/* 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/. */ + /** * Client for communicating with the pq daemon. */ diff --git a/profile-query-cli/src/constants.ts b/profile-query-cli/src/constants.ts new file mode 100644 index 0000000000..03c7e476bc --- /dev/null +++ b/profile-query-cli/src/constants.ts @@ -0,0 +1,16 @@ +/* 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/. */ + +/** + * Build-time constants injected by the build script. + */ + +// This global is defined via esbuild's define option. +declare const __BUILD_HASH__: string; + +/** + * Unique hash for this build, used to detect version mismatches + * between client and daemon. + */ +export const BUILD_HASH = __BUILD_HASH__; diff --git a/src/profile-query-cli/daemon.ts b/profile-query-cli/src/daemon.ts similarity index 96% rename from src/profile-query-cli/daemon.ts rename to profile-query-cli/src/daemon.ts index c60990b063..cfefdeb6a6 100644 --- a/src/profile-query-cli/daemon.ts +++ b/profile-query-cli/src/daemon.ts @@ -1,3 +1,7 @@ +/* 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/. */ + /** * Daemon process for pq. * Loads a profile and listens for commands on a Unix socket (or named pipe on Windows). @@ -5,7 +9,7 @@ import * as net from 'net'; import * as fs from 'fs'; -import { ProfileQuerier } from '../profile-query'; +import { ProfileQuerier } from '../../src/profile-query'; import type { ClientCommand, ClientMessage, @@ -183,7 +187,7 @@ export class Daemon { .catch((error) => { const errorResponse: ServerResponse = { type: 'error', - error: String(error), + error: error instanceof Error ? error.message : String(error), }; socket.write(JSON.stringify(errorResponse) + '\n'); }); @@ -191,7 +195,7 @@ export class Daemon { console.error(`Failed to parse message: ${error}`); const errorResponse: ServerResponse = { type: 'error', - error: `Failed to parse message: ${error}`, + error: `Failed to parse message: ${error instanceof Error ? error.message : String(error)}`, }; socket.write(JSON.stringify(errorResponse) + '\n'); } diff --git a/src/profile-query-cli/formatters.ts b/profile-query-cli/src/formatters.ts similarity index 99% rename from src/profile-query-cli/formatters.ts rename to profile-query-cli/src/formatters.ts index e1a847f5b0..2f1c0362c2 100644 --- a/src/profile-query-cli/formatters.ts +++ b/profile-query-cli/src/formatters.ts @@ -1,3 +1,7 @@ +/* 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/. */ + /** * Text formatters for CommandResult types. * These functions convert structured JSON results into human-readable text output. @@ -22,7 +26,7 @@ import type { MarkerGroupData, CallTreeNode, } from './protocol'; -import { truncateFunctionName } from '../profile-query/function-list'; +import { truncateFunctionName } from '../../src/profile-query/function-list'; /** * Format a SessionContext as a compact header line. diff --git a/src/profile-query-cli/index.ts b/profile-query-cli/src/index.ts similarity index 98% rename from src/profile-query-cli/index.ts rename to profile-query-cli/src/index.ts index 3376c26dff..07ea747ae5 100644 --- a/src/profile-query-cli/index.ts +++ b/profile-query-cli/src/index.ts @@ -1,3 +1,7 @@ +/* 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/. */ + /** * CLI entry point for pq (Profile Querier). * @@ -14,7 +18,7 @@ * * Run: * pq (if pq is in PATH) - * ./dist/pq.js (direct invocation) + * ./profile-query-cli/dist/pq.js (direct invocation) * * Helper scripts: * ./bin/pq-test Quick smoke test @@ -25,7 +29,7 @@ import * as path from 'path'; import * as os from 'os'; -import guideText from './guide.txt'; +import guideText from '../guide.txt'; import minimist from 'minimist'; import { startDaemon } from './daemon'; import { sendCommand, startNewDaemon, stopDaemon } from './client'; diff --git a/src/profile-query-cli/protocol.ts b/profile-query-cli/src/protocol.ts similarity index 86% rename from src/profile-query-cli/protocol.ts rename to profile-query-cli/src/protocol.ts index 9291583a60..b353cbdf6f 100644 --- a/src/profile-query-cli/protocol.ts +++ b/profile-query-cli/src/protocol.ts @@ -1,3 +1,7 @@ +/* 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/. */ + /** * Protocol for communication between pq client and daemon. * Messages are sent as line-delimited JSON over Unix domain sockets. @@ -28,8 +32,8 @@ export type { MarkerStackResult, StackTraceData, ProfileInfoResult, -} from '../profile-query/types'; -export type { CallTreeCollectionOptions } from '../profile-query/formatters/call-tree'; +} from '../../src/profile-query/types'; +export type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; // Import types for use in type definitions import type { @@ -49,8 +53,8 @@ import type { ThreadSamplesBottomUpResult, ThreadMarkersResult, ThreadFunctionsResult, -} from '../profile-query/types'; -import type { CallTreeCollectionOptions } from '../profile-query/formatters/call-tree'; +} from '../../src/profile-query/types'; +import type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; export type ClientMessage = | { type: 'command'; command: ClientCommand } diff --git a/src/profile-query-cli/session.ts b/profile-query-cli/src/session.ts similarity index 96% rename from src/profile-query-cli/session.ts rename to profile-query-cli/src/session.ts index a02c48a277..14beb76fdf 100644 --- a/src/profile-query-cli/session.ts +++ b/profile-query-cli/src/session.ts @@ -1,3 +1,7 @@ +/* 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/. */ + /** * Session management for pq daemon. * Handles session files, socket paths, and current session tracking. diff --git a/src/profile-query-cli/tests/basic.test.ts b/profile-query-cli/src/test/integration/basic.test.ts similarity index 93% rename from src/profile-query-cli/tests/basic.test.ts rename to profile-query-cli/src/test/integration/basic.test.ts index 262308bee7..482738a363 100644 --- a/src/profile-query-cli/tests/basic.test.ts +++ b/profile-query-cli/src/test/integration/basic.test.ts @@ -1,3 +1,7 @@ +/* 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/. */ + /** * Basic CLI functionality tests. * Migrated from bin/pq-test bash script. diff --git a/src/profile-query-cli/tests/daemon-startup.test.ts b/profile-query-cli/src/test/integration/daemon-startup.test.ts similarity index 94% rename from src/profile-query-cli/tests/daemon-startup.test.ts rename to profile-query-cli/src/test/integration/daemon-startup.test.ts index 676c85dea5..f5ace135eb 100644 --- a/src/profile-query-cli/tests/daemon-startup.test.ts +++ b/profile-query-cli/src/test/integration/daemon-startup.test.ts @@ -1,3 +1,7 @@ +/* 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/. */ + /** * Tests for two-phase daemon startup behavior. * Verifies socket creation before profile loading and proper status reporting. @@ -12,7 +16,7 @@ import { pqFail, type PqTestContext, } from './utils'; -import { getSocketPath } from '../session'; +import { getSocketPath } from '../../session'; describe('daemon startup (two-phase)', () => { let ctx: PqTestContext; diff --git a/src/profile-query-cli/tests/sessions.test.ts b/profile-query-cli/src/test/integration/sessions.test.ts similarity index 93% rename from src/profile-query-cli/tests/sessions.test.ts rename to profile-query-cli/src/test/integration/sessions.test.ts index c8aacb3bcd..a432f20409 100644 --- a/src/profile-query-cli/tests/sessions.test.ts +++ b/profile-query-cli/src/test/integration/sessions.test.ts @@ -1,3 +1,7 @@ +/* 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/. */ + /** * Multi-session tests. * Migrated from bin/pq-test-multi bash script. diff --git a/src/profile-query-cli/tests/setup.ts b/profile-query-cli/src/test/integration/setup.ts similarity index 51% rename from src/profile-query-cli/tests/setup.ts rename to profile-query-cli/src/test/integration/setup.ts index a47b214d7c..9ba519e3c8 100644 --- a/src/profile-query-cli/tests/setup.ts +++ b/profile-query-cli/src/test/integration/setup.ts @@ -1,3 +1,7 @@ +/* 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/. */ + /** * Jest setup for CLI integration tests. * These tests only need jest-extended, not the full browser test setup. diff --git a/src/profile-query-cli/tests/utils.ts b/profile-query-cli/src/test/integration/utils.ts similarity index 94% rename from src/profile-query-cli/tests/utils.ts rename to profile-query-cli/src/test/integration/utils.ts index cf778a967d..ea8f5c957f 100644 --- a/src/profile-query-cli/tests/utils.ts +++ b/profile-query-cli/src/test/integration/utils.ts @@ -1,3 +1,7 @@ +/* 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/. */ + /** * Utilities for CLI integration tests. */ @@ -7,7 +11,7 @@ import { mkdtemp, readdir, readFile, rm } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; -const PQ_BIN = './src/profile-query-cli/dist/pq.js'; +const PQ_BIN = './profile-query-cli/dist/pq.js'; /** * Simple command execution result. diff --git a/src/test/unit/profile-query-cli/__snapshots__/call-tree-formatting.test.ts.snap b/profile-query-cli/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap similarity index 100% rename from src/test/unit/profile-query-cli/__snapshots__/call-tree-formatting.test.ts.snap rename to profile-query-cli/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap diff --git a/src/test/unit/profile-query-cli/call-tree-formatting.test.ts b/profile-query-cli/src/test/unit/call-tree-formatting.test.ts similarity index 97% rename from src/test/unit/profile-query-cli/call-tree-formatting.test.ts rename to profile-query-cli/src/test/unit/call-tree-formatting.test.ts index bcc760e300..c471ccd4b3 100644 --- a/src/test/unit/profile-query-cli/call-tree-formatting.test.ts +++ b/profile-query-cli/src/test/unit/call-tree-formatting.test.ts @@ -2,22 +2,22 @@ * 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/. */ -import { FunctionMap } from '../../../profile-query/function-map'; -import { collectCallTree } from '../../../profile-query/formatters/call-tree'; +import { FunctionMap } from 'firefox-profiler/profile-query/function-map'; +import { collectCallTree } from 'firefox-profiler/profile-query/formatters/call-tree'; import type { ThreadSamplesTopDownResult, ThreadSamplesBottomUpResult, SessionContext, WithContext, -} from '../../../profile-query/types'; -import { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; -import { storeWithProfile } from '../../fixtures/stores'; +} from 'firefox-profiler/profile-query/types'; +import { getProfileFromTextSamples } from 'firefox-profiler/test/fixtures/profiles/processed-profile'; +import { storeWithProfile } from 'firefox-profiler/test/fixtures/stores'; import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { formatThreadSamplesTopDownResult, formatThreadSamplesBottomUpResult, -} from '../../../profile-query-cli/formatters'; -import type { CallTreeCollectionOptions } from '../../../profile-query/formatters/call-tree'; +} from '../../formatters'; +import type { CallTreeCollectionOptions } from 'firefox-profiler/profile-query/formatters/call-tree'; import { getCallTree, computeCallTreeTimings, diff --git a/src/test/unit/profile-query-cli/client.test.ts b/profile-query-cli/src/test/unit/client.test.ts similarity index 100% rename from src/test/unit/profile-query-cli/client.test.ts rename to profile-query-cli/src/test/unit/client.test.ts diff --git a/src/test/unit/profile-query-cli/daemon.test.ts b/profile-query-cli/src/test/unit/daemon.test.ts similarity index 100% rename from src/test/unit/profile-query-cli/daemon.test.ts rename to profile-query-cli/src/test/unit/daemon.test.ts diff --git a/src/test/unit/profile-query-cli/session.test.ts b/profile-query-cli/src/test/unit/session.test.ts similarity index 98% rename from src/test/unit/profile-query-cli/session.test.ts rename to profile-query-cli/src/test/unit/session.test.ts index c6ad3c7e79..70bb52928c 100644 --- a/src/test/unit/profile-query-cli/session.test.ts +++ b/profile-query-cli/src/test/unit/session.test.ts @@ -29,8 +29,8 @@ import { cleanupSession, validateSession, listSessions, -} from 'firefox-profiler/profile-query-cli/session'; -import type { SessionMetadata } from 'firefox-profiler/profile-query-cli/protocol'; +} from '../../session'; +import type { SessionMetadata } from '../../protocol'; const TEST_BUILD_HASH = 'test-build-hash'; diff --git a/scripts/build-profile-query-cli.mjs b/scripts/build-profile-query-cli.mjs index 1627abb842..16d0b7054f 100644 --- a/scripts/build-profile-query-cli.mjs +++ b/scripts/build-profile-query-cli.mjs @@ -2,16 +2,16 @@ * 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/. */ import esbuild from 'esbuild'; -import { execSync } from 'child_process'; +import { chmodSync } from 'fs'; import { nodeBaseConfig } from './lib/esbuild-configs.mjs'; const BUILD_HASH = Date.now().toString(36); const profileQueryCliConfig = { ...nodeBaseConfig, - entryPoints: ['src/profile-query-cli/index.ts'], + entryPoints: ['profile-query-cli/src/index.ts'], loader: { ...nodeBaseConfig.loader, '.txt': 'text' }, - outfile: 'src/profile-query-cli/dist/pq.js', + outfile: 'profile-query-cli/dist/pq.js', minify: true, banner: { js: '#!/usr/bin/env node\n\n// Polyfill browser globals for Node.js\nglobalThis.self = globalThis;', @@ -24,7 +24,7 @@ const profileQueryCliConfig = { async function build() { await esbuild.build(profileQueryCliConfig); - execSync('chmod +x src/profile-query-cli/dist/pq.js'); + chmodSync('profile-query-cli/dist/pq.js', 0o755); console.log('✅ Profile-query-cli build completed'); } diff --git a/src/profile-query-cli/constants.ts b/src/profile-query-cli/constants.ts deleted file mode 100644 index 3dbb385995..0000000000 --- a/src/profile-query-cli/constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Build-time constants injected by webpack. - */ - -// This global is defined by webpack.DefinePlugin -declare const __BUILD_HASH__: string; - -/** - * Unique hash for this build, used to detect version mismatches - * between client and daemon. - */ -export const BUILD_HASH = __BUILD_HASH__; diff --git a/tsconfig.json b/tsconfig.json index 28b14b261a..9f651ae75c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,6 +38,11 @@ // React & JSX "jsx": "react-jsx" }, - "include": ["src/**/*.ts", "src/**/*.tsx", "__mocks__/**/*.ts"], + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "profile-query-cli/**/*.ts", + "__mocks__/**/*.ts" + ], "exclude": ["node_modules", "dist"] } From 31aa1b7294e96bbfb0713d248db62c9eb67b9b8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 10 Apr 2026 02:02:32 +0200 Subject: [PATCH 16/63] Improve collection of thread samples --- src/profile-query/formatters/thread-info.ts | 20 ++++++- src/profile-query/process-thread-list.ts | 5 +- .../profile-query/process-thread-list.test.ts | 57 +++++++++++++++++++ .../profile-query/profile-querier.test.ts | 23 ++++++++ 4 files changed, 102 insertions(+), 3 deletions(-) diff --git a/src/profile-query/formatters/thread-info.ts b/src/profile-query/formatters/thread-info.ts index 44b8742687..f3af9d46a3 100644 --- a/src/profile-query/formatters/thread-info.ts +++ b/src/profile-query/formatters/thread-info.ts @@ -199,8 +199,24 @@ export function collectThreadSamples( }; if (roots.length > 0) { - const heaviestPath: CallNodePath = - callTree._internal.findHeaviestPathInSubtree(roots[0]); + let heaviestPath: CallNodePath = []; + let maxSelfSamples = Number.NEGATIVE_INFINITY; + + for (const root of roots) { + const candidatePath = callTree._internal.findHeaviestPathInSubtree(root); + const leafNodeIndex = + callTree._callNodeInfo.getCallNodeIndexFromPath(candidatePath); + + if (leafNodeIndex === null) { + continue; + } + + const candidateSelfSamples = callTree.getNodeData(leafNodeIndex).self; + if (candidateSelfSamples > maxSelfSamples) { + heaviestPath = candidatePath; + maxSelfSamples = candidateSelfSamples; + } + } if (heaviestPath.length > 0) { const callNodeInfo = callTree._callNodeInfo; diff --git a/src/profile-query/process-thread-list.ts b/src/profile-query/process-thread-list.ts index e5830b5617..024a6804cc 100644 --- a/src/profile-query/process-thread-list.ts +++ b/src/profile-query/process-thread-list.ts @@ -134,6 +134,7 @@ export function buildProcessThreadList( const processesToShow = allProcesses.filter( (p) => top5ProcessPids.has(p.pid) || top20ThreadPids.has(p.pid) ); + const shownProcessPids = new Set(processesToShow.map((p) => p.pid)); // Build the result list const result: ProcessListItem[] = processesToShow.map((processInfo) => { @@ -180,7 +181,9 @@ export function buildProcessThreadList( }); // Calculate remaining processes summary - const remainingProcesses = allProcesses.slice(processesToShow.length); + const remainingProcesses = allProcesses.filter( + (processInfo) => !shownProcessPids.has(processInfo.pid) + ); let remainingProcessesInfo: ProcessThreadListResult['remainingProcesses'] = undefined; diff --git a/src/test/unit/profile-query/process-thread-list.test.ts b/src/test/unit/profile-query/process-thread-list.test.ts index 08020b444c..5070144322 100644 --- a/src/test/unit/profile-query/process-thread-list.test.ts +++ b/src/test/unit/profile-query/process-thread-list.test.ts @@ -84,6 +84,63 @@ describe('buildProcessThreadList', function () { expect(result.processes.map((p) => p.pid)).toContain('p2'); }); + it('summarizes only hidden processes in remainingProcesses', function () { + const threads: ThreadInfo[] = [ + createThread(0, 'p1', 'P1-A', 110), + createThread(1, 'p1', 'P1-B', 109), + createThread(2, 'p1', 'P1-C', 108), + createThread(3, 'p1', 'P1-D', 107), + createThread(4, 'p2', 'P2-A', 106), + createThread(5, 'p2', 'P2-B', 105), + createThread(6, 'p2', 'P2-C', 104), + createThread(7, 'p2', 'P2-D', 103), + createThread(8, 'p3', 'P3-A', 102), + createThread(9, 'p3', 'P3-B', 101), + createThread(10, 'p3', 'P3-C', 100), + createThread(11, 'p3', 'P3-D', 99), + createThread(12, 'p4', 'P4-A', 98), + createThread(13, 'p4', 'P4-B', 97), + createThread(14, 'p4', 'P4-C', 96), + createThread(15, 'p4', 'P4-D', 95), + createThread(16, 'p5', 'P5-A', 94), + createThread(17, 'p5', 'P5-B', 93), + createThread(18, 'p5', 'P5-C', 92), + createThread(19, 'p6', 'P6-top-thread', 91), + createThread(20, 'p6', 'P6-low-thread', 1), + createThread(21, 'p7', 'P7-A', 30), + createThread(22, 'p7', 'P7-B', 28), + createThread(23, 'p8', 'P8-A', 29), + createThread(24, 'p8', 'P8-B', 28), + ]; + + const processIndexMap = new Map([ + ['p1', 0], + ['p2', 1], + ['p3', 2], + ['p4', 3], + ['p5', 4], + ['p6', 5], + ['p7', 6], + ['p8', 7], + ]); + + const result = buildProcessThreadList(threads, processIndexMap); + + expect(result.processes.map((p) => p.pid)).toEqual([ + 'p1', + 'p2', + 'p3', + 'p4', + 'p5', + 'p6', + ]); + expect(result.remainingProcesses).toEqual({ + count: 2, + combinedCpuMs: 115, + maxCpuMs: 58, + }); + }); + it('shows up to 5 threads per process when none are in top 20', function () { // Create 4 high-CPU processes that will be in top 5 const threads: ThreadInfo[] = []; diff --git a/src/test/unit/profile-query/profile-querier.test.ts b/src/test/unit/profile-query/profile-querier.test.ts index cd4c01dd0a..ff9090a394 100644 --- a/src/test/unit/profile-query/profile-querier.test.ts +++ b/src/test/unit/profile-query/profile-querier.test.ts @@ -207,4 +207,27 @@ describe('ProfileQuerier', function () { expect(hasD || hasE).toBe(true); }); }); + + describe('threadSamples', function () { + it('searches all roots when choosing the heaviest stack', async function () { + const { profile } = getProfileFromTextSamples(` + 0 1 2 3 4 + A A A X X + B C D Y Y + `); + + const store = storeWithProfile(profile); + const state = store.getState(); + const rootRange = getProfileRootRange(state); + const querier = new ProfileQuerier(store, rootRange); + + const samples = await querier.threadSamples(); + + expect(samples.heaviestStack.selfSamples).toBe(2); + expect(samples.heaviestStack.frames.map((frame) => frame.name)).toEqual([ + 'X', + 'Y', + ]); + }); + }); }); From 6c0f01d0cbdda8112e1b17e81fbbf9803636966c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 10 Apr 2026 02:19:59 +0200 Subject: [PATCH 17/63] Properly stop the old daemon if there is a hash mismatch --- profile-query-cli/src/client.ts | 150 +++++++++++++----- profile-query-cli/src/session.ts | 53 +++++-- .../src/test/integration/basic.test.ts | 57 ++++++- .../src/test/unit/session.test.ts | 66 ++++++++ 4 files changed, 277 insertions(+), 49 deletions(-) diff --git a/profile-query-cli/src/client.ts b/profile-query-cli/src/client.ts index e8a4b845f7..fce22702fd 100644 --- a/profile-query-cli/src/client.ts +++ b/profile-query-cli/src/client.ts @@ -22,11 +22,108 @@ import { getCurrentSessionId, getCurrentSocketPath, getSocketPath, + isProcessRunning, loadSessionMetadata, validateSession, + waitForProcessExit, } from './session'; import { BUILD_HASH } from './constants'; +type BuildMismatchShutdownResult = 'stopped' | 'already-dead' | 'still-running'; + +async function sendMessageToSocket( + socketPath: string, + message: ClientMessage, + timeoutMs: number = 30000 +): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect(socketPath); + let buffer = ''; + + socket.on('connect', () => { + socket.write(JSON.stringify(message) + '\n'); + }); + + socket.on('data', (data) => { + buffer += data.toString(); + + const newlineIndex = buffer.indexOf('\n'); + if (newlineIndex !== -1) { + const line = buffer.substring(0, newlineIndex); + try { + const response = JSON.parse(line) as ServerResponse; + socket.end(); + resolve(response); + } catch (error) { + reject(new Error(`Failed to parse response: ${error}`)); + } + } + }); + + socket.on('error', (error) => { + reject(new Error(`Socket error: ${error.message}`)); + }); + + socket.on('timeout', () => { + socket.destroy(); + reject(new Error('Connection timeout')); + }); + + socket.setTimeout(timeoutMs); + }); +} + +async function attemptShutdownOnBuildMismatch( + sessionDir: string, + sessionId: string, + socketPath: string, + pid: number +): Promise { + if (process.platform !== 'win32' && !fs.existsSync(socketPath)) { + if (!isProcessRunning(pid)) { + cleanupSession(sessionDir, sessionId); + return 'already-dead'; + } + return 'still-running'; + } + + try { + const response = await sendMessageToSocket( + socketPath, + { type: 'shutdown' }, + 2000 + ); + + if (response.type !== 'success') { + console.error( + `Failed to stop mismatched daemon for session ${sessionId}: unexpected response ${response.type}` + ); + return isProcessRunning(pid) ? 'still-running' : 'already-dead'; + } + + const exited = await waitForProcessExit(pid); + if (!exited) { + console.error( + `Mismatched daemon for session ${sessionId} acknowledged shutdown but did not exit within timeout` + ); + return 'still-running'; + } + + cleanupSession(sessionDir, sessionId); + return 'stopped'; + } catch (error) { + if (!isProcessRunning(pid)) { + cleanupSession(sessionDir, sessionId); + return 'already-dead'; + } + + console.error( + `Failed to stop mismatched daemon for session ${sessionId}: ${error}` + ); + return 'still-running'; + } +} + /** * Send a message to the daemon and return the raw response. */ @@ -52,9 +149,20 @@ async function sendRawMessage( // Check build hash matches const metadata = loadSessionMetadata(sessionDir, resolvedSessionId); if (metadata && metadata.buildHash !== BUILD_HASH) { - cleanupSession(sessionDir, resolvedSessionId); + const shutdownResult = await attemptShutdownOnBuildMismatch( + sessionDir, + resolvedSessionId, + metadata.socketPath, + metadata.pid + ); + + const shutdownMessage = + shutdownResult === 'stopped' || shutdownResult === 'already-dead' + ? 'The daemon is no longer running.' + : 'The daemon may still be running; stop it before reusing this session id.'; + throw new Error( - `Session ${resolvedSessionId} was built with a different version (daemon: ${metadata.buildHash}, client: ${BUILD_HASH}). The daemon has been stopped. Please run "pq load " again.` + `Session ${resolvedSessionId} was built with a different version (daemon: ${metadata.buildHash}, client: ${BUILD_HASH}). ${shutdownMessage} Please run "pq load " again.` ); } @@ -70,43 +178,7 @@ async function sendRawMessage( throw new Error(`Socket not found for session ${resolvedSessionId}`); } - return new Promise((resolve, reject) => { - const socket = net.connect(socketPath); - let buffer = ''; - - socket.on('connect', () => { - // Send the message - socket.write(JSON.stringify(message) + '\n'); - }); - - socket.on('data', (data) => { - buffer += data.toString(); - - // Look for complete response (newline-delimited JSON) - const newlineIndex = buffer.indexOf('\n'); - if (newlineIndex !== -1) { - const line = buffer.substring(0, newlineIndex); - try { - const response = JSON.parse(line) as ServerResponse; - socket.end(); - resolve(response); - } catch (error) { - reject(new Error(`Failed to parse response: ${error}`)); - } - } - }); - - socket.on('error', (error) => { - reject(new Error(`Socket error: ${error.message}`)); - }); - - socket.on('timeout', () => { - socket.destroy(); - reject(new Error('Connection timeout')); - }); - - socket.setTimeout(30000); // 30 second timeout - }); + return sendMessageToSocket(socketPath, message); } /** diff --git a/profile-query-cli/src/session.ts b/profile-query-cli/src/session.ts index 14beb76fdf..e45513a1e4 100644 --- a/profile-query-cli/src/session.ts +++ b/profile-query-cli/src/session.ts @@ -13,6 +13,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import * as crypto from 'crypto'; import type { SessionMetadata } from './protocol'; /** @@ -31,13 +32,26 @@ export function generateSessionId(): string { return Math.random().toString(36).substring(2, 15); } +/** + * Get a stable namespace for a session directory. + */ +export function getSessionDirNamespace(sessionDir: string): string { + const resolvedSessionDir = path.resolve(sessionDir).toLowerCase(); + return crypto + .createHash('sha256') + .update(resolvedSessionDir) + .digest('hex') + .slice(0, 12); +} + /** * Get the socket path for a session. * On Windows, returns a named pipe path. On Unix, returns a .sock file path. */ export function getSocketPath(sessionDir: string, sessionId: string): string { if (process.platform === 'win32') { - return `\\\\.\\pipe\\pq-${sessionId}`; + const sessionDirNamespace = getSessionDirNamespace(sessionDir); + return `\\\\.\\pipe\\pq-${sessionDirNamespace}-${sessionId}`; } return path.join(sessionDir, `${sessionId}.sock`); } @@ -110,7 +124,7 @@ export function getCurrentSessionId(sessionDir: string): string | null { try { return fs.readFileSync(currentSessionFile, 'utf-8').trim(); } catch (_error) { - console.error(`Failed to read current session: ${_error}`); + // ENOENT means no active session — not an error condition return null; } } @@ -141,6 +155,26 @@ export function isProcessRunning(pid: number): boolean { } } +/** + * Wait for a process to exit. + */ +export async function waitForProcessExit( + pid: number, + timeoutMs: number = 5000, + pollIntervalMs: number = 50 +): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (!isProcessRunning(pid)) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + return !isProcessRunning(pid); +} + /** * Clean up a session's files. */ @@ -152,19 +186,20 @@ export function cleanupSession(sessionDir: string, sessionId: string): void { // const logPath = getLogPath(sessionDir, sessionId); // Remove socket file (Unix only — named pipes on Windows are not filesystem files) - if (process.platform !== 'win32' && fs.existsSync(socketPath)) { - fs.unlinkSync(socketPath); + // Use force: true to silently ignore ENOENT — client and daemon may both call + // cleanupSession concurrently during version-mismatch shutdown, so the file + // may already be gone by the time the second caller tries to unlink it. + if (process.platform !== 'win32') { + fs.rmSync(socketPath, { force: true }); } // Remove metadata file - if (fs.existsSync(metadataPath)) { - fs.unlinkSync(metadataPath); - } + fs.rmSync(metadataPath, { force: true }); // Remove current session file if it points to this session const currentSessionId = getCurrentSessionId(sessionDir); - if (currentSessionId === sessionId && fs.existsSync(currentSessionFile)) { - fs.unlinkSync(currentSessionFile); + if (currentSessionId === sessionId) { + fs.rmSync(currentSessionFile, { force: true }); } } diff --git a/profile-query-cli/src/test/integration/basic.test.ts b/profile-query-cli/src/test/integration/basic.test.ts index 482738a363..cf0660ec20 100644 --- a/profile-query-cli/src/test/integration/basic.test.ts +++ b/profile-query-cli/src/test/integration/basic.test.ts @@ -7,7 +7,8 @@ * Migrated from bin/pq-test bash script. */ -import { readdir } from 'fs/promises'; +import { readdir, readFile, writeFile } from 'fs/promises'; +import { join } from 'path'; import { createTestContext, cleanupTestContext, @@ -96,4 +97,58 @@ describe('pq basic functionality', () => { expect(result2.exitCode).toBe(0); expect(result2.stdout).toEqual(result1.stdout); }); + + test('build hash mismatch stops the daemon before cleaning up the session', async () => { + const loadResult = await pq(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + ]); + + expect(typeof loadResult.stdout).toBe('string'); + const match = loadResult.stdout.match(/Session started: (\w+)/); + expect(match).toBeTruthy(); + const sessionId = match![1]; + + const metadataPath = join(ctx.sessionDir, `${sessionId}.json`); + const metadata = JSON.parse(await readFile(metadataPath, 'utf-8')) as { + buildHash: string; + pid: number; + }; + + await writeFile( + metadataPath, + JSON.stringify({ ...metadata, buildHash: 'intentionally-mismatched' }) + ); + + const result = await pqFail(ctx, ['profile', 'info']); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('was built with a different version'); + expect(output).toContain('The daemon is no longer running'); + + await expectDaemonToExit(metadata.pid); + + const files = await readdir(ctx.sessionDir); + expect(files).not.toContain(`${sessionId}.json`); + expect(files).not.toContain(`${sessionId}.sock`); + }); }); + +async function expectDaemonToExit(pid: number): Promise { + for (let attempt = 0; attempt < 30; attempt++) { + if (!isProcessRunning(pid)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + throw new Error(`Daemon process ${pid} did not exit in time`); +} + +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} diff --git a/profile-query-cli/src/test/unit/session.test.ts b/profile-query-cli/src/test/unit/session.test.ts index 70bb52928c..b2877098ea 100644 --- a/profile-query-cli/src/test/unit/session.test.ts +++ b/profile-query-cli/src/test/unit/session.test.ts @@ -14,9 +14,11 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { spawn } from 'child_process'; import { ensureSessionDir, generateSessionId, + getSessionDirNamespace, getSocketPath, getLogPath, getMetadataPath, @@ -26,6 +28,7 @@ import { getCurrentSessionId, getCurrentSocketPath, isProcessRunning, + waitForProcessExit, cleanupSession, validateSession, listSessions, @@ -36,6 +39,10 @@ const TEST_BUILD_HASH = 'test-build-hash'; describe('profile-query-cli session management', function () { let testSessionDir: string; + const platformDescriptor = Object.getOwnPropertyDescriptor( + process, + 'platform' + ); beforeEach(function () { // Create a unique temp directory for each test @@ -43,6 +50,10 @@ describe('profile-query-cli session management', function () { }); afterEach(function () { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + // Clean up test directory if (fs.existsSync(testSessionDir)) { fs.rmSync(testSessionDir, { recursive: true, force: true }); @@ -89,6 +100,35 @@ describe('profile-query-cli session management', function () { expect(socketPath).toBe(path.join(testSessionDir, 'test123.sock')); }); + it('namespaces Windows pipe paths by session directory', function () { + Object.defineProperty(process, 'platform', { + value: 'win32', + }); + + const firstSocketPath = getSocketPath('C:\\pq\\alpha', 'test123'); + const secondSocketPath = getSocketPath('C:\\pq\\beta', 'test123'); + const thirdSocketPath = getSocketPath('C:\\PQ\\ALPHA', 'test123'); + + expect(firstSocketPath).toMatch( + /^\\\\\.\\pipe\\pq-[0-9a-f]{12}-test123$/ + ); + expect(secondSocketPath).toMatch( + /^\\\\\.\\pipe\\pq-[0-9a-f]{12}-test123$/ + ); + expect(firstSocketPath).not.toBe(secondSocketPath); + expect(firstSocketPath).toBe(thirdSocketPath); + }); + + it('generates a stable namespace from the session directory', function () { + const firstNamespace = getSessionDirNamespace('C:\\pq\\alpha'); + const secondNamespace = getSessionDirNamespace('C:\\pq\\beta'); + const thirdNamespace = getSessionDirNamespace('C:\\PQ\\ALPHA'); + + expect(firstNamespace).toMatch(/^[0-9a-f]{12}$/); + expect(firstNamespace).not.toBe(secondNamespace); + expect(firstNamespace).toBe(thirdNamespace); + }); + it('getLogPath returns correct path', function () { const sessionId = 'test123'; const logPath = getLogPath(testSessionDir, sessionId); @@ -185,6 +225,32 @@ describe('profile-query-cli session management', function () { it('returns false for non-existent PID', function () { expect(isProcessRunning(999999)).toBe(false); }); + + it('waits for a process to exit', async function () { + const child = spawn(process.execPath, [ + '-e', + 'setTimeout(() => process.exit(0), 100)', + ]); + + const exited = await waitForProcessExit(child.pid!, 2000, 10); + + expect(exited).toBe(true); + }); + + it('times out if a process does not exit', async function () { + const child = spawn(process.execPath, [ + '-e', + 'setTimeout(() => process.exit(0), 5000)', + ]); + + try { + const exited = await waitForProcessExit(child.pid!, 50, 10); + expect(exited).toBe(false); + } finally { + child.kill('SIGTERM'); + await waitForProcessExit(child.pid!, 2000, 10); + } + }); }); describe('cleanupSession', function () { From 1d3fe83d0b94d3ba646651c0e09d86e2a4b92179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 10 Apr 2026 02:23:40 +0200 Subject: [PATCH 18/63] Error on session id clash --- profile-query-cli/src/client.ts | 13 +++++++++ .../src/test/integration/sessions.test.ts | 28 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/profile-query-cli/src/client.ts b/profile-query-cli/src/client.ts index fce22702fd..0b401c0d06 100644 --- a/profile-query-cli/src/client.ts +++ b/profile-query-cli/src/client.ts @@ -251,6 +251,19 @@ export async function startNewDaemon( // session to wait for (avoids race condition with existing sessions) const targetSessionId = sessionId || generateSessionId(); + if (sessionId) { + const existingSession = validateSession(sessionDir, targetSessionId); + if (existingSession) { + throw new Error( + `Session ${targetSessionId} is already running. Stop it first or choose a different session id.` + ); + } + + if (loadSessionMetadata(sessionDir, targetSessionId)) { + cleanupSession(sessionDir, targetSessionId); + } + } + // Get the path to the current script (pq.js) const scriptPath = process.argv[1]; diff --git a/profile-query-cli/src/test/integration/sessions.test.ts b/profile-query-cli/src/test/integration/sessions.test.ts index a432f20409..b34a793834 100644 --- a/profile-query-cli/src/test/integration/sessions.test.ts +++ b/profile-query-cli/src/test/integration/sessions.test.ts @@ -11,6 +11,7 @@ import { createTestContext, cleanupTestContext, pq, + pqFail, type PqTestContext, } from './utils'; @@ -118,4 +119,31 @@ describe('pq multiple concurrent sessions', () => { const result = await pq(ctx, ['list-sessions']); expect(result.stdout).toContain('Found 0 running sessions'); }); + + test('reusing a live explicit session id fails without replacing the daemon', async () => { + const sessionId = 'shared-session'; + + await pq(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + sessionId, + ]); + + const secondLoad = await pqFail(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + sessionId, + ]); + + expect(secondLoad.exitCode).not.toBe(0); + const output = + String(secondLoad.stdout || '') + String(secondLoad.stderr || ''); + expect(output).toContain(`Session ${sessionId} is already running`); + + const result = await pq(ctx, ['profile', 'info', '--session', sessionId]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('This profile contains'); + }); }); From aa463431b5676663d2046d6104f4f49c65597683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 10 Apr 2026 02:27:20 +0200 Subject: [PATCH 19/63] Numeric cli arguments should treat 0 as a real value --- profile-query-cli/src/index.ts | 22 ++++---- .../src/test/integration/basic.test.ts | 51 +++++++++++++++++++ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/profile-query-cli/src/index.ts b/profile-query-cli/src/index.ts index 07ea747ae5..10b88fb20b 100644 --- a/profile-query-cli/src/index.ts +++ b/profile-query-cli/src/index.ts @@ -339,13 +339,13 @@ async function main(): Promise { let functionFilters: FunctionFilterOptions | undefined; if (subcommand === 'markers') { - const hasSearch = !!argv.search; - const hasMinDuration = !!argv['min-duration']; - const hasMaxDuration = !!argv['max-duration']; - const hasCategory = !!argv.category; + const hasSearch = argv.search !== undefined; + const hasMinDuration = argv['min-duration'] !== undefined; + const hasMaxDuration = argv['max-duration'] !== undefined; + const hasCategory = argv.category !== undefined; const hasStack = argv['has-stack']; - const hasLimit = !!argv.limit; - const hasGroupBy = !!argv['group-by']; + const hasLimit = argv.limit !== undefined; + const hasGroupBy = argv['group-by'] !== undefined; const hasAutoGroup = argv['auto-group']; if ( @@ -407,9 +407,9 @@ async function main(): Promise { // Parse function filter options if this is a functions command if (subcommand === 'functions') { - const hasSearch = !!argv.search; - const hasMinSelf = !!argv['min-self']; - const hasLimit = !!argv.limit; + const hasSearch = argv.search !== undefined; + const hasMinSelf = argv['min-self'] !== undefined; + const hasLimit = argv.limit !== undefined; if (hasSearch || hasMinSelf || hasLimit) { functionFilters = {}; @@ -443,8 +443,8 @@ async function main(): Promise { subcommand === 'samples-top-down' || subcommand === 'samples-bottom-up' ) { - const hasMaxLines = !!argv['max-lines']; - const hasScoring = !!argv.scoring; + const hasMaxLines = argv['max-lines'] !== undefined; + const hasScoring = argv.scoring !== undefined; if (hasMaxLines || hasScoring) { callTreeOptions = {}; diff --git a/profile-query-cli/src/test/integration/basic.test.ts b/profile-query-cli/src/test/integration/basic.test.ts index cf0660ec20..262c16d8e5 100644 --- a/profile-query-cli/src/test/integration/basic.test.ts +++ b/profile-query-cli/src/test/integration/basic.test.ts @@ -98,6 +98,57 @@ describe('pq basic functionality', () => { expect(result2.stdout).toEqual(result1.stdout); }); + test('numeric zero marker filters are preserved instead of being ignored', async () => { + await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const minDurationResult = await pq(ctx, [ + 'thread', + 'markers', + '--json', + '--min-duration', + '0', + ]); + expect(minDurationResult.stdout).toContain('"minDuration": 0'); + + const maxDurationResult = await pq(ctx, [ + 'thread', + 'markers', + '--json', + '--max-duration', + '0', + ]); + expect(maxDurationResult.stdout).toContain('"maxDuration": 0'); + }); + + test('numeric zero function filters are preserved instead of being ignored', async () => { + await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const result = await pq(ctx, [ + 'thread', + 'functions', + '--json', + '--min-self', + '0', + ]); + + expect(result.stdout).toContain('"minSelf": 0'); + }); + + test('max-lines=0 is rejected instead of silently falling back to the default', async () => { + await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const result = await pqFail(ctx, [ + 'thread', + 'samples-top-down', + '--max-lines', + '0', + ]); + + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('--max-lines must be a positive integer'); + }); + test('build hash mismatch stops the daemon before cleaning up the session', async () => { const loadResult = await pq(ctx, [ 'load', From 1ca317847369a929f7636e092912d615e7b794fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 10 Apr 2026 14:15:14 +0200 Subject: [PATCH 20/63] Drop idle samples by default and add a --incude-idle flag for including them --- profile-query-cli/src/daemon.ts | 14 ++- profile-query-cli/src/formatters.ts | 21 +++- profile-query-cli/src/index.ts | 8 ++ profile-query-cli/src/protocol.ts | 1 + src/profile-query/index.ts | 151 +++++++++++++++++++++------- src/profile-query/types.ts | 4 + 6 files changed, 156 insertions(+), 43 deletions(-) diff --git a/profile-query-cli/src/daemon.ts b/profile-query-cli/src/daemon.ts index cfefdeb6a6..853f8ec5c2 100644 --- a/profile-query-cli/src/daemon.ts +++ b/profile-query-cli/src/daemon.ts @@ -297,16 +297,21 @@ export class Daemon { } return this.querier!.threadSelect(command.thread); case 'samples': - return this.querier!.threadSamples(command.thread); + return this.querier!.threadSamples( + command.thread, + command.includeIdle + ); case 'samples-top-down': return this.querier!.threadSamplesTopDown( command.thread, - command.callTreeOptions + command.callTreeOptions, + command.includeIdle ); case 'samples-bottom-up': return this.querier!.threadSamplesBottomUp( command.thread, - command.callTreeOptions + command.callTreeOptions, + command.includeIdle ); case 'markers': return this.querier!.threadMarkers( @@ -316,7 +321,8 @@ export class Daemon { case 'functions': return this.querier!.threadFunctions( command.thread, - command.functionFilters + command.functionFilters, + command.includeIdle ); default: throw assertExhaustiveCheck(command); diff --git a/profile-query-cli/src/formatters.ts b/profile-query-cli/src/formatters.ts index 2f1c0362c2..9a17b8b227 100644 --- a/profile-query-cli/src/formatters.ts +++ b/profile-query-cli/src/formatters.ts @@ -494,9 +494,12 @@ export function formatThreadSamplesResult( result: WithContext ): string { const contextHeader = formatContextHeader(result.context); + const activeOnlyNote = result.activeOnly + ? 'Note: active samples only (idle excluded) — use --include-idle to include idle samples.\n\n' + : ''; let output = `${contextHeader} -Thread: ${result.friendlyThreadName}\n\n`; +Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}`; // Top functions by total time output += 'Top Functions (by total time):\n'; @@ -579,9 +582,12 @@ export function formatThreadSamplesTopDownResult( result: WithContext ): string { const contextHeader = formatContextHeader(result.context); + const activeOnlyNote = result.activeOnly + ? 'Note: active samples only (idle excluded) — use --include-idle to include idle samples.\n\n' + : ''; let output = `${contextHeader} -Thread: ${result.friendlyThreadName}\n\n`; +Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}`; // Top-down call tree output += formatCallTree(result.regularCallTree, 'Top-Down'); @@ -596,9 +602,12 @@ export function formatThreadSamplesBottomUpResult( result: WithContext ): string { const contextHeader = formatContextHeader(result.context); + const activeOnlyNote = result.activeOnly + ? 'Note: active samples only (idle excluded) — use --include-idle to include idle samples.\n\n' + : ''; let output = `${contextHeader} -Thread: ${result.friendlyThreadName}\n\n`; +Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}`; // Bottom-up call tree (inverted tree shows callers) if (result.invertedCallTree) { @@ -798,6 +807,12 @@ export function formatThreadFunctionsResult( `Functions in thread ${result.threadHandle} (${result.friendlyThreadName}) — ${result.filteredFunctionCount} functions${filterSuffix}\n` ); + if (result.activeOnly) { + lines.push( + 'Note: active samples only (idle excluded) — use --include-idle to include idle samples.\n' + ); + } + if (result.filteredFunctionCount === 0) { if (hasFilters) { lines.push('No functions match the specified filters.'); diff --git a/profile-query-cli/src/index.ts b/profile-query-cli/src/index.ts index 10b88fb20b..9dfeaf2321 100644 --- a/profile-query-cli/src/index.ts +++ b/profile-query-cli/src/index.ts @@ -480,6 +480,13 @@ async function main(): Promise { } } + const includeIdle = + (subcommand === 'samples' || + subcommand === 'samples-top-down' || + subcommand === 'samples-bottom-up' || + subcommand === 'functions') && + argv['include-idle'] === true; + if ( subcommand === 'info' || subcommand === 'select' || @@ -495,6 +502,7 @@ async function main(): Promise { command: 'thread', subcommand, thread, + includeIdle: includeIdle || undefined, markerFilters, functionFilters, callTreeOptions, diff --git a/profile-query-cli/src/protocol.ts b/profile-query-cli/src/protocol.ts index b353cbdf6f..250fde2507 100644 --- a/profile-query-cli/src/protocol.ts +++ b/profile-query-cli/src/protocol.ts @@ -79,6 +79,7 @@ export type ClientCommand = | 'markers' | 'functions'; thread?: string; + includeIdle?: boolean; markerFilters?: MarkerFilterOptions; functionFilters?: FunctionFilterOptions; callTreeOptions?: CallTreeCollectionOptions; diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 9fc5359f5e..f919fe5ea3 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -25,11 +25,13 @@ import { import { getAllCommittedRanges, getSelectedThreadIndexes, + getTransformStack, } from 'firefox-profiler/selectors/url-state'; import { commitRange, popCommittedRanges, changeSelectedThreads, + addTransformToStack, } from '../actions/profile-view'; import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { TimestampManager } from './timestamps'; @@ -72,7 +74,8 @@ import type { } from './types'; import type { CallTreeCollectionOptions } from './formatters/call-tree'; -import type { StartEndRange } from 'firefox-profiler/types'; +import { getThreadsKey } from 'firefox-profiler/profile-logic/profile-data'; +import type { StartEndRange, ThreadIndex } from 'firefox-profiler/types'; import type { Store } from '../types/store'; export class ProfileQuerier { @@ -133,43 +136,73 @@ export class ProfileQuerier { } async threadSamples( - threadHandle?: string + threadHandle?: string, + includeIdle: boolean = false ): Promise> { - const result = await collectThreadSamples( - this._store, - this._threadMap, - this._functionMap, - threadHandle - ); - return { ...result, context: this._getContext() }; + const activeOnly = !includeIdle; + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const collect = () => + collectThreadSamples( + this._store, + this._threadMap, + this._functionMap, + threadHandle + ); + const result = activeOnly + ? this._withDroppedIdle(threadIndexes, collect) + : collect(); + return { ...result, activeOnly, context: this._getContext() }; } async threadSamplesTopDown( threadHandle?: string, - callTreeOptions?: CallTreeCollectionOptions + callTreeOptions?: CallTreeCollectionOptions, + includeIdle: boolean = false ): Promise> { - const result = await collectThreadSamplesTopDown( - this._store, - this._threadMap, - this._functionMap, - threadHandle, - callTreeOptions - ); - return { ...result, context: this._getContext() }; + const activeOnly = !includeIdle; + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const collect = () => + collectThreadSamplesTopDown( + this._store, + this._threadMap, + this._functionMap, + threadHandle, + callTreeOptions + ); + const result = activeOnly + ? this._withDroppedIdle(threadIndexes, collect) + : collect(); + return { ...result, activeOnly, context: this._getContext() }; } async threadSamplesBottomUp( threadHandle?: string, - callTreeOptions?: CallTreeCollectionOptions + callTreeOptions?: CallTreeCollectionOptions, + includeIdle: boolean = false ): Promise> { - const result = await collectThreadSamplesBottomUp( - this._store, - this._threadMap, - this._functionMap, - threadHandle, - callTreeOptions - ); - return { ...result, context: this._getContext() }; + const activeOnly = !includeIdle; + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const collect = () => + collectThreadSamplesBottomUp( + this._store, + this._threadMap, + this._functionMap, + threadHandle, + callTreeOptions + ); + const result = activeOnly + ? this._withDroppedIdle(threadIndexes, collect) + : collect(); + return { ...result, activeOnly, context: this._getContext() }; } /** @@ -397,6 +430,42 @@ export class ProfileQuerier { return `Selected ${threadIndexes.size} threads: ${threadHandle} (${names})`; } + /** + * Apply a drop-category transform for the Idle category around a computation, + * then restore the transform stack to its previous state. + * If the profile has no Idle category, fn() is called without changes. + */ + private _withDroppedIdle(threadIndexes: Set, fn: () => T): T { + const state = this._store.getState(); + const profile = getProfile(state); + const idleCategoryIndex = + profile.meta.categories?.findIndex((c) => c.name === 'Idle') ?? -1; + + if (idleCategoryIndex === -1) { + return fn(); + } + + const threadsKey = getThreadsKey(threadIndexes); + const stackLengthBefore = getTransformStack(state, threadsKey).length; + + this._store.dispatch( + addTransformToStack(threadsKey, { + type: 'drop-category', + category: idleCategoryIndex, + }) + ); + + try { + return fn(); + } finally { + this._store.dispatch({ + type: 'POP_TRANSFORMS_FROM_STACK', + threadsKey, + firstPoppedFilterIndex: stackLengthBefore, + }); + } + } + /** * Get current session context for display in command outputs. * This is a lightweight version of getStatus() that includes only @@ -643,16 +712,26 @@ export class ProfileQuerier { */ async threadFunctions( threadHandle?: string, - filterOptions?: FunctionFilterOptions + filterOptions?: FunctionFilterOptions, + includeIdle: boolean = false ): Promise> { - const result = await collectThreadFunctions( - this._store, - this._threadMap, - this._functionMap, - threadHandle, - filterOptions - ); - return { ...result, context: this._getContext() }; + const activeOnly = !includeIdle; + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const collect = () => + collectThreadFunctions( + this._store, + this._threadMap, + this._functionMap, + threadHandle, + filterOptions + ); + const result = activeOnly + ? this._withDroppedIdle(threadIndexes, collect) + : collect(); + return { ...result, activeOnly, context: this._getContext() }; } /** diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 323ad0e797..8e3688f009 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -187,6 +187,7 @@ export type ThreadSamplesResult = { type: 'thread-samples'; threadHandle: string; friendlyThreadName: string; + activeOnly?: boolean; topFunctionsByTotal: TopFunctionInfo[]; topFunctionsBySelf: TopFunctionInfo[]; heaviestStack: { @@ -207,6 +208,7 @@ export type ThreadSamplesTopDownResult = { type: 'thread-samples-top-down'; threadHandle: string; friendlyThreadName: string; + activeOnly?: boolean; regularCallTree: CallTreeNode; }; @@ -214,6 +216,7 @@ export type ThreadSamplesBottomUpResult = { type: 'thread-samples-bottom-up'; threadHandle: string; friendlyThreadName: string; + activeOnly?: boolean; invertedCallTree: CallTreeNode | null; }; @@ -315,6 +318,7 @@ export type ThreadFunctionsResult = { type: 'thread-functions'; threadHandle: string; friendlyThreadName: string; + activeOnly?: boolean; totalFunctionCount: number; filteredFunctionCount: number; filters?: { From fe27a5fbb193f38b16e019955056881dceb1a735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 10 Apr 2026 21:31:28 +0200 Subject: [PATCH 21/63] Add sample filtering and update guide/help texts --- profile-query-cli/guide.txt | 31 ++-- profile-query-cli/src/daemon.ts | 9 +- profile-query-cli/src/formatters.ts | 9 +- profile-query-cli/src/index.ts | 15 +- profile-query-cli/src/protocol.ts | 1 + src/profile-query/index.ts | 73 ++++++++-- src/profile-query/types.ts | 3 + .../profile-query/profile-querier.test.ts | 136 ++++++++++++++++++ 8 files changed, 247 insertions(+), 30 deletions(-) diff --git a/profile-query-cli/guide.txt b/profile-query-cli/guide.txt index d9049c6863..37a548427b 100644 --- a/profile-query-cli/guide.txt +++ b/profile-query-cli/guide.txt @@ -28,10 +28,18 @@ CORE WORKFLOW pq thread select t-0 Select a specific thread (persists for future commands) Step 4: Analyze CPU usage - pq thread samples Hot functions list (self time, sorted by impact) - pq thread samples-top-down Top-down call tree (where does CPU time originate) - pq thread samples-bottom-up Bottom-up call tree (what calls the hot functions) - pq thread functions All functions with self/total CPU percentages + pq thread samples Hot functions list (self time, sorted by impact) + pq thread samples-top-down Top-down call tree (where does CPU time originate) + pq thread samples-bottom-up Bottom-up call tree (what calls the hot functions) + pq thread functions All functions with self/total CPU percentages + + All samples commands exclude idle by default so percentages reflect active CPU time. + Use --include-idle to include idle samples (e.g. to see what fraction of wall time is idle). + + Use --search to focus the call tree on paths containing a specific function: + pq thread samples-top-down --search GC + pq thread samples-bottom-up --search "JS::Compile" + pq thread samples --search malloc Step 5: Analyze markers (browser events, timers, etc.) pq thread markers All markers with aggregated stats @@ -88,8 +96,9 @@ COMMON ANALYSIS PATTERNS Find what's consuming CPU: pq thread info Pick the main thread (usually GeckoMain) pq thread select t-0 - pq thread samples Top hot functions by self time + pq thread samples Top hot functions by self time (active only) pq thread samples-top-down See the full call tree + pq thread samples-top-down --search GC Focus tree on paths through "GC" Investigate a jank/slow period: pq thread markers --min-duration 50 Find long-running markers (>50ms = jank) @@ -131,6 +140,10 @@ UNDERSTANDING THE OUTPUT of the call stack. Higher sample counts = more time spent. Counts are relative within a profile. + By default, samples commands drop idle samples before computing percentages, so + percentages reflect how the thread spent its active CPU time. Pass --include-idle + to include idle samples and see percentages relative to wall time instead. + Call tree views: samples-top-down Root frames at top, drilling down to leaves. Use to understand the calling structure and where time originates. @@ -149,10 +162,12 @@ TIPS process and thread, or --search to filter by name, pid, or tid - Select a thread before querying -- GeckoMain is usually the main thread - Self time is more actionable: focus on high self-time functions for optimization - - Idle self time is not a finding on its own -- a longer profile or an inactive thread will - naturally show more idle time. Only investigate idle time if it appears during a period the - thread should be busy (e.g. inside a zoom on a slow operation), which may indicate lock + - Samples percentages are relative to active (non-idle) time by default. Use --include-idle + to see wall-time percentages. Idle time is not a finding on its own -- only investigate it + during a period the thread should be busy (e.g. inside a zoom), which may indicate lock contention or blocking on another thread. + - Use --search to narrow the call tree to branches you care about: --search GC shows only + call paths that pass through a GC-related function - Zoom into slow periods: use "zoom push m-" to focus on a slow marker - Use --json when you need to process or filter results programmatically - Marker handles are stable: you can refer back to m-1234 multiple times diff --git a/profile-query-cli/src/daemon.ts b/profile-query-cli/src/daemon.ts index 853f8ec5c2..04f6ebebf0 100644 --- a/profile-query-cli/src/daemon.ts +++ b/profile-query-cli/src/daemon.ts @@ -299,19 +299,22 @@ export class Daemon { case 'samples': return this.querier!.threadSamples( command.thread, - command.includeIdle + command.includeIdle, + command.search ); case 'samples-top-down': return this.querier!.threadSamplesTopDown( command.thread, command.callTreeOptions, - command.includeIdle + command.includeIdle, + command.search ); case 'samples-bottom-up': return this.querier!.threadSamplesBottomUp( command.thread, command.callTreeOptions, - command.includeIdle + command.includeIdle, + command.search ); case 'markers': return this.querier!.threadMarkers( diff --git a/profile-query-cli/src/formatters.ts b/profile-query-cli/src/formatters.ts index 9a17b8b227..efe370bab8 100644 --- a/profile-query-cli/src/formatters.ts +++ b/profile-query-cli/src/formatters.ts @@ -497,9 +497,10 @@ export function formatThreadSamplesResult( const activeOnlyNote = result.activeOnly ? 'Note: active samples only (idle excluded) — use --include-idle to include idle samples.\n\n' : ''; + const searchNote = result.search ? `Search: "${result.search}"\n\n` : ''; let output = `${contextHeader} -Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}`; +Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}`; // Top functions by total time output += 'Top Functions (by total time):\n'; @@ -585,9 +586,10 @@ export function formatThreadSamplesTopDownResult( const activeOnlyNote = result.activeOnly ? 'Note: active samples only (idle excluded) — use --include-idle to include idle samples.\n\n' : ''; + const searchNote = result.search ? `Search: "${result.search}"\n\n` : ''; let output = `${contextHeader} -Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}`; +Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}`; // Top-down call tree output += formatCallTree(result.regularCallTree, 'Top-Down'); @@ -605,9 +607,10 @@ export function formatThreadSamplesBottomUpResult( const activeOnlyNote = result.activeOnly ? 'Note: active samples only (idle excluded) — use --include-idle to include idle samples.\n\n' : ''; + const searchNote = result.search ? `Search: "${result.search}"\n\n` : ''; let output = `${contextHeader} -Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}`; +Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}`; // Bottom-up call tree (inverted tree shows callers) if (result.invertedCallTree) { diff --git a/profile-query-cli/src/index.ts b/profile-query-cli/src/index.ts index 9dfeaf2321..46588f8e4b 100644 --- a/profile-query-cli/src/index.ts +++ b/profile-query-cli/src/index.ts @@ -102,7 +102,8 @@ Options: --thread Specify thread by handle (e.g., t-0, t-1) --marker Specify marker by handle (e.g., m-1, m-2) --function Specify function by handle (e.g., f-1, f-2) - --search Search/filter by substring (for 'thread markers' and 'thread functions') + --search Search/filter by substring (markers, functions, and samples call trees) + --include-idle Include idle samples in percentages (samples commands exclude idle by default) --category Filter markers by category name (case-insensitive substring match) --min-duration Filter markers by minimum duration in milliseconds --max-duration Filter markers by maximum duration in milliseconds @@ -155,7 +156,10 @@ Examples: pq list-sessions pq thread samples-top-down --max-lines 50 pq thread samples-top-down --scoring exponential-0.8 + pq thread samples-top-down --search GC pq thread samples-bottom-up --max-lines 200 --scoring harmonic-1.0 + pq thread samples-bottom-up --search "JS::Compile" + pq thread samples --include-idle `); } @@ -487,6 +491,14 @@ async function main(): Promise { subcommand === 'functions') && argv['include-idle'] === true; + const samplesSearch = + (subcommand === 'samples' || + subcommand === 'samples-top-down' || + subcommand === 'samples-bottom-up') && + typeof argv.search === 'string' + ? (argv.search as string) + : undefined; + if ( subcommand === 'info' || subcommand === 'select' || @@ -503,6 +515,7 @@ async function main(): Promise { subcommand, thread, includeIdle: includeIdle || undefined, + search: samplesSearch, markerFilters, functionFilters, callTreeOptions, diff --git a/profile-query-cli/src/protocol.ts b/profile-query-cli/src/protocol.ts index 250fde2507..8d852103cb 100644 --- a/profile-query-cli/src/protocol.ts +++ b/profile-query-cli/src/protocol.ts @@ -80,6 +80,7 @@ export type ClientCommand = | 'functions'; thread?: string; includeIdle?: boolean; + search?: string; markerFilters?: MarkerFilterOptions; functionFilters?: FunctionFilterOptions; callTreeOptions?: CallTreeCollectionOptions; diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index f919fe5ea3..0a13b24840 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -26,12 +26,14 @@ import { getAllCommittedRanges, getSelectedThreadIndexes, getTransformStack, + getCurrentSearchString, } from 'firefox-profiler/selectors/url-state'; import { commitRange, popCommittedRanges, changeSelectedThreads, addTransformToStack, + changeCallTreeSearchString, } from '../actions/profile-view'; import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { TimestampManager } from './timestamps'; @@ -137,7 +139,8 @@ export class ProfileQuerier { async threadSamples( threadHandle?: string, - includeIdle: boolean = false + includeIdle: boolean = false, + search?: string ): Promise> { const activeOnly = !includeIdle; const threadIndexes = @@ -151,16 +154,25 @@ export class ProfileQuerier { this._functionMap, threadHandle ); - const result = activeOnly - ? this._withDroppedIdle(threadIndexes, collect) - : collect(); - return { ...result, activeOnly, context: this._getContext() }; + const withIdle = activeOnly + ? () => this._withDroppedIdle(threadIndexes, collect) + : collect; + const result = search + ? this._withCallTreeSearch(search, withIdle) + : withIdle(); + return { + ...result, + activeOnly, + search: search || undefined, + context: this._getContext(), + }; } async threadSamplesTopDown( threadHandle?: string, callTreeOptions?: CallTreeCollectionOptions, - includeIdle: boolean = false + includeIdle: boolean = false, + search?: string ): Promise> { const activeOnly = !includeIdle; const threadIndexes = @@ -175,16 +187,25 @@ export class ProfileQuerier { threadHandle, callTreeOptions ); - const result = activeOnly - ? this._withDroppedIdle(threadIndexes, collect) - : collect(); - return { ...result, activeOnly, context: this._getContext() }; + const withIdle = activeOnly + ? () => this._withDroppedIdle(threadIndexes, collect) + : collect; + const result = search + ? this._withCallTreeSearch(search, withIdle) + : withIdle(); + return { + ...result, + activeOnly, + search: search || undefined, + context: this._getContext(), + }; } async threadSamplesBottomUp( threadHandle?: string, callTreeOptions?: CallTreeCollectionOptions, - includeIdle: boolean = false + includeIdle: boolean = false, + search?: string ): Promise> { const activeOnly = !includeIdle; const threadIndexes = @@ -199,10 +220,18 @@ export class ProfileQuerier { threadHandle, callTreeOptions ); - const result = activeOnly - ? this._withDroppedIdle(threadIndexes, collect) - : collect(); - return { ...result, activeOnly, context: this._getContext() }; + const withIdle = activeOnly + ? () => this._withDroppedIdle(threadIndexes, collect) + : collect; + const result = search + ? this._withCallTreeSearch(search, withIdle) + : withIdle(); + return { + ...result, + activeOnly, + search: search || undefined, + context: this._getContext(), + }; } /** @@ -466,6 +495,20 @@ export class ProfileQuerier { } } + /** + * Set the call tree search string around a computation, then restore the + * previous search string. + */ + private _withCallTreeSearch(searchString: string, fn: () => T): T { + const previousSearch = getCurrentSearchString(this._store.getState()); + this._store.dispatch(changeCallTreeSearchString(searchString)); + try { + return fn(); + } finally { + this._store.dispatch(changeCallTreeSearchString(previousSearch)); + } + } + /** * Get current session context for display in command outputs. * This is a lightweight version of getStatus() that includes only diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 8e3688f009..68bf081265 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -188,6 +188,7 @@ export type ThreadSamplesResult = { threadHandle: string; friendlyThreadName: string; activeOnly?: boolean; + search?: string; topFunctionsByTotal: TopFunctionInfo[]; topFunctionsBySelf: TopFunctionInfo[]; heaviestStack: { @@ -209,6 +210,7 @@ export type ThreadSamplesTopDownResult = { threadHandle: string; friendlyThreadName: string; activeOnly?: boolean; + search?: string; regularCallTree: CallTreeNode; }; @@ -217,6 +219,7 @@ export type ThreadSamplesBottomUpResult = { threadHandle: string; friendlyThreadName: string; activeOnly?: boolean; + search?: string; invertedCallTree: CallTreeNode | null; }; diff --git a/src/test/unit/profile-query/profile-querier.test.ts b/src/test/unit/profile-query/profile-querier.test.ts index ff9090a394..5337a429ab 100644 --- a/src/test/unit/profile-query/profile-querier.test.ts +++ b/src/test/unit/profile-query/profile-querier.test.ts @@ -208,6 +208,142 @@ describe('ProfileQuerier', function () { }); }); + describe('search', function () { + // Helper to collect all function names in a call tree + function collectTreeNames(node: { + name: string; + children?: { name: string; children?: unknown[] }[]; + }): string[] { + const names: string[] = [node.name]; + if (node.children) { + for (const child of node.children) { + names.push( + ...collectTreeNames(child as Parameters[0]) + ); + } + } + return names; + } + + it('threadSamplesTopDown with search only shows branches containing the search term', async function () { + // Two separate call trees: + // A → B → C (3 samples) + // X → Y → Z (2 samples) + const { profile } = getProfileFromTextSamples(` + 0 1 2 3 4 + A A A X X + B B B Y Y + C C C Z Z + `); + + const store = storeWithProfile(profile); + const querier = new ProfileQuerier( + store, + getProfileRootRange(store.getState()) + ); + + const result = await querier.threadSamplesTopDown( + undefined, + undefined, + false, + 'X' + ); + + expect(result.search).toBe('X'); + const names = collectTreeNames(result.regularCallTree); + expect(names).toContain('X'); + expect(names).toContain('Y'); + expect(names).toContain('Z'); + expect(names).not.toContain('A'); + expect(names).not.toContain('B'); + expect(names).not.toContain('C'); + }); + + it('threadSamplesBottomUp with search only shows branches containing the search term', async function () { + const { profile } = getProfileFromTextSamples(` + 0 1 2 3 4 + A A A X X + B B B Y Y + C C C Z Z + `); + + const store = storeWithProfile(profile); + const querier = new ProfileQuerier( + store, + getProfileRootRange(store.getState()) + ); + + const result = await querier.threadSamplesBottomUp( + undefined, + undefined, + false, + 'X' + ); + + expect(result.search).toBe('X'); + const names = result.invertedCallTree + ? collectTreeNames(result.invertedCallTree) + : []; + // Bottom-up tree roots by leaf function — X branch leaves should appear + expect(names.some((n) => ['X', 'Y', 'Z'].includes(n))).toBe(true); + expect(names).not.toContain('A'); + expect(names).not.toContain('B'); + expect(names).not.toContain('C'); + }); + + it('threadSamples with search filters the top functions list', async function () { + const { profile } = getProfileFromTextSamples(` + 0 1 2 3 4 + A A A X X + B B B Y Y + C C C Z Z + `); + + const store = storeWithProfile(profile); + const querier = new ProfileQuerier( + store, + getProfileRootRange(store.getState()) + ); + + const result = await querier.threadSamples(undefined, false, 'X'); + + expect(result.search).toBe('X'); + const allNames = [ + ...result.topFunctionsByTotal.map((f) => f.name), + ...result.topFunctionsBySelf.map((f) => f.name), + ]; + expect(allNames.some((n) => ['X', 'Y', 'Z'].includes(n))).toBe(true); + expect(allNames).not.toContain('A'); + expect(allNames).not.toContain('B'); + expect(allNames).not.toContain('C'); + }); + + it('search does not persist to subsequent calls', async function () { + const { profile } = getProfileFromTextSamples(` + 0 1 2 3 4 + A A A X X + B B B Y Y + C C C Z Z + `); + + const store = storeWithProfile(profile); + const querier = new ProfileQuerier( + store, + getProfileRootRange(store.getState()) + ); + + // Call with search + await querier.threadSamplesTopDown(undefined, undefined, false, 'X'); + + // Call without search — should restore and show all branches + const result = await querier.threadSamplesTopDown(); + const names = collectTreeNames(result.regularCallTree); + expect(names).toContain('A'); + expect(names).toContain('X'); + expect(result.search).toBeUndefined(); + }); + }); + describe('threadSamples', function () { it('searches all roots when choosing the heaviest stack', async function () { const { profile } = getProfileFromTextSamples(` From ecff4d64550418b0781e08c3ecb7b8734010dc06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Sat, 11 Apr 2026 11:48:40 +0200 Subject: [PATCH 22/63] Make function handles persistent across sessions by using IndexIntoFuncTable directly --- pq-filters-and-bookmarks-proposal.md | 15 +- pq-todo.md | 8 +- profile-query-cli/src/formatters.ts | 3 +- profile-query-cli/src/index.ts | 8 +- .../call-tree-formatting.test.ts.snap | 232 +++++++++--------- .../test/unit/call-tree-formatting.test.ts | 21 +- src/profile-query/README.md | 3 + src/profile-query/formatters/call-tree.ts | 22 +- src/profile-query/formatters/thread-info.ts | 38 +-- src/profile-query/function-list.ts | 29 +-- src/profile-query/function-map.ts | 76 ++---- src/profile-query/index.ts | 64 ++--- src/profile-query/types.ts | 3 - src/test/unit/profile-query/call-tree.test.ts | 225 ++++------------- .../unit/profile-query/function-list.test.ts | 41 +--- 15 files changed, 260 insertions(+), 528 deletions(-) diff --git a/pq-filters-and-bookmarks-proposal.md b/pq-filters-and-bookmarks-proposal.md index de3fb18408..03a49f3fad 100644 --- a/pq-filters-and-bookmarks-proposal.md +++ b/pq-filters-and-bookmarks-proposal.md @@ -20,7 +20,7 @@ Based on feedback, this proposal includes: 7. ✅ **Balanced zoom syntax** - `zoom push ts-6,ts-7` and `zoom push --marker m-158` 8. ✅ **Single filter stack** - one ordered stack for sample/stack filters (order matters for dependencies) 9. ✅ **Prefix means exact sequence** - `--includes-prefix A,B,C` means starts with A→B→C exactly -10. ✅ **Per-thread filter stacks** - each thread has its own filter context (handles are thread-specific!) +10. ✅ **Per-thread filter stacks** - each thread has its own filter context 11. ✅ **Separate marker filters** - marker display filtering independent from sample/stack filtering --- @@ -114,7 +114,12 @@ pq zoom push --spike 1 # Zoom to first CPU spike (future feature) ### 4. Sticky Sample/Stack Filters (Per-Thread) -Each thread has its own filter stack. Filters are **scoped to the current thread** because function handles (f-142) are thread-specific. +Each thread has its own filter stack. Filters are **scoped to the current thread** because they apply to that thread's sample data and transforms. + +Function handles in filter expressions refer to canonical shared `funcTable` +indices, e.g. `f-142` means shared function index 142 for the loaded profile. +The filter state remains per-thread because the sample data and transform stacks +are per-thread. ```bash # Select thread first @@ -509,7 +514,10 @@ $ pq thread samples ### Per-Thread Filter Scoping -**Why per-thread?** Function handles (e.g., `f-142`) are thread-specific. A function handle on thread t-0 has no meaning on thread t-93. +**Why per-thread?** Filter state is thread-scoped because it operates on a thread's sample data and transform stack. + +Function handles themselves are not thread-local in the current implementation; +they identify shared functions for the loaded profile. ```bash pq thread select t-0 @@ -519,7 +527,6 @@ pq thread functions --search Present pq filter push --includes-function f-142 # Filter for t-0 pq thread select t-93 -# f-142 on t-93 is a DIFFERENT function! # t-93 has its own independent filter stack (empty initially) ``` diff --git a/pq-todo.md b/pq-todo.md index 2d4dda6b6a..ad9c6019e9 100644 --- a/pq-todo.md +++ b/pq-todo.md @@ -160,6 +160,10 @@ Regular Call Tree (showing top 30 functions, 249 lines omitted): When zoomed, shows both view and full profile percentages: +Note: In the current implementation, function handles are canonical shared +`funcTable` indices. `f-123` means "function at shared funcTable index 123", not +the first function encountered in the current session. + ```bash # When zoomed: Functions (by self time): @@ -184,9 +188,11 @@ pq thread markers --thread t-93 --search Paint The `pq function info` command displays both full and short names: +Note: Function info is resolved from `profile.shared.funcTable`, so the handle is +profile-global rather than thread-local. + ```bash Function f-1: - Thread: t-0 (GeckoMain) Full name: win32u.dll!ZwUserMsgWaitForMultipleObjectsEx Short name: ZwUserMsgWaitForMultipleObjectsEx Is JS: false diff --git a/profile-query-cli/src/formatters.ts b/profile-query-cli/src/formatters.ts index efe370bab8..1d74d0e446 100644 --- a/profile-query-cli/src/formatters.ts +++ b/profile-query-cli/src/formatters.ts @@ -106,7 +106,7 @@ export function formatFunctionExpandResult( const contextHeader = formatContextHeader(result.context); return `${contextHeader} -Function ${result.functionHandle} (thread ${result.threadHandle}): +Function ${result.functionHandle}: ${result.fullName}`; } @@ -120,7 +120,6 @@ export function formatFunctionInfoResult( let output = `${contextHeader} Function ${result.functionHandle}: - Thread: ${result.threadHandle} (${result.threadName}) Full name: ${result.fullName} Short name: ${result.name} Is JS: ${result.isJS} diff --git a/profile-query-cli/src/index.ts b/profile-query-cli/src/index.ts index 46588f8e4b..b3f77f2994 100644 --- a/profile-query-cli/src/index.ts +++ b/profile-query-cli/src/index.ts @@ -88,7 +88,7 @@ Commands: thread functions List all functions with CPU percentages marker info Show detailed marker information (e.g., m-1234) marker stack Show full stack trace for a marker (e.g., m-1234) - function expand Show full untruncated function name (e.g., f-1) + function expand Show full untruncated function name (e.g., f-123) function info Show detailed function information zoom push Push a zoom range (e.g., 2.7,3.1 or ts-g,ts-G or m-158) zoom pop Pop the most recent zoom range @@ -101,7 +101,7 @@ Options: --session Use a specific session (default: current session) --thread Specify thread by handle (e.g., t-0, t-1) --marker Specify marker by handle (e.g., m-1, m-2) - --function Specify function by handle (e.g., f-1, f-2) + --function Specify function by handle (e.g., f-123, f-456) --search Search/filter by substring (markers, functions, and samples call trees) --include-idle Include idle samples in percentages (samples commands exclude idle by default) --category Filter markers by category name (case-insensitive substring match) @@ -145,8 +145,8 @@ Examples: pq thread markers --search DOMEvent --group-by field:eventType pq marker info m-1234 pq marker stack m-1234 - pq function expand f-12 - pq function info f-12 + pq function expand f-123 + pq function info f-123 pq zoom push 2.7,3.1 pq zoom push m-158 pq zoom pop diff --git a/profile-query-cli/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap b/profile-query-cli/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap index 6e7e0a7fd6..83e6161c62 100644 --- a/profile-query-cli/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap +++ b/profile-query-cli/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap @@ -6,18 +6,18 @@ exports[`call tree formatting bottom-up view complex nested trees formats a deep Thread: Test Thread Bottom-Up Call Tree: -f-1. Idle [total: 50.0%, self: 50.0%] -└─ f-5. Loop [total: 50.0%, self: 0.0%] - f-6. Main [total: 50.0%, self: 0.0%] -f-2. Think [total: 25.0%, self: 25.0%] -└─ f-7. AI [total: 25.0%, self: 0.0%] - f-4. Tick [total: 25.0%, self: 0.0%] - f-5. Loop [total: 25.0%, self: 0.0%] - f-6. Main [total: 25.0%, self: 0.0%] -f-3. Phys [total: 25.0%, self: 25.0%] -└─ f-4. Tick [total: 25.0%, self: 0.0%] - f-5. Loop [total: 25.0%, self: 0.0%] - f-6. Main [total: 25.0%, self: 0.0%]" +f-6. Idle [total: 50.0%, self: 50.0%] +└─ f-1. Loop [total: 50.0%, self: 0.0%] + f-0. Main [total: 50.0%, self: 0.0%] +f-4. Think [total: 25.0%, self: 25.0%] +└─ f-3. AI [total: 25.0%, self: 0.0%] + f-2. Tick [total: 25.0%, self: 0.0%] + f-1. Loop [total: 25.0%, self: 0.0%] + f-0. Main [total: 25.0%, self: 0.0%] +f-5. Phys [total: 25.0%, self: 25.0%] +└─ f-2. Tick [total: 25.0%, self: 0.0%] + f-1. Loop [total: 25.0%, self: 0.0%] + f-0. Main [total: 25.0%, self: 0.0%]" `; exports[`call tree formatting bottom-up view complex nested trees shows which functions call a leaf function 1`] = ` @@ -26,11 +26,11 @@ exports[`call tree formatting bottom-up view complex nested trees shows which fu Thread: Test Thread Bottom-Up Call Tree: -f-1. E [total: 100.0%, self: 100.0%] -└─ f-2. D [total: 100.0%, self: 0.0%] - ├─ f-3. A [total: 33.3%, self: 0.0%] - ├─ f-4. B [total: 33.3%, self: 0.0%] - └─ f-5. C [total: 33.3%, self: 0.0%]" +f-2. E [total: 100.0%, self: 100.0%] +└─ f-1. D [total: 100.0%, self: 0.0%] + ├─ f-0. A [total: 33.3%, self: 0.0%] + ├─ f-3. B [total: 33.3%, self: 0.0%] + └─ f-4. C [total: 33.3%, self: 0.0%]" `; exports[`call tree formatting bottom-up view different scoring strategies exponential-0.9 strategy for bottom-up 1`] = ` @@ -39,15 +39,15 @@ exports[`call tree formatting bottom-up view different scoring strategies expone Thread: Test Thread Bottom-Up Call Tree: -f-1. G [total: 20.0%, self: 20.0%] -└─ f-7. D [total: 20.0%, self: 0.0%] +f-2. G [total: 20.0%, self: 20.0%] +└─ f-1. D [total: 20.0%, self: 0.0%] └─ ... (1 more children: combined 20.0%, max 20.0%) -f-2. E [total: 20.0%, self: 20.0%] -└─ f-6. A [total: 20.0%, self: 0.0%] -f-3. F [total: 20.0%, self: 20.0%] -└─ f-6. A [total: 20.0%, self: 0.0%] -f-4. B [total: 20.0%, self: 20.0%] -f-5. C [total: 20.0%, self: 20.0%]" +f-3. E [total: 20.0%, self: 20.0%] +└─ f-0. A [total: 20.0%, self: 0.0%] +f-4. F [total: 20.0%, self: 20.0%] +└─ f-0. A [total: 20.0%, self: 0.0%] +f-5. B [total: 20.0%, self: 20.0%] +f-6. C [total: 20.0%, self: 20.0%]" `; exports[`call tree formatting bottom-up view elision bugs each parent node should have at most one elision marker 1`] = ` @@ -56,9 +56,9 @@ exports[`call tree formatting bottom-up view elision bugs each parent node shoul Thread: Test Thread Top-Down Call Tree: -f-1. A [total: 100.0%, self: 0.0%] -└─ f-2. B [total: 100.0%, self: 0.0%] - f-3. C [total: 100.0%, self: 0.0%] +f-0. A [total: 100.0%, self: 0.0%] +└─ f-1. B [total: 100.0%, self: 0.0%] + f-2. C [total: 100.0%, self: 0.0%] └─ ... (1 more children: combined 100.0%, max 100.0%)" `; @@ -70,8 +70,8 @@ Thread: Test Thread Bottom-Up Call Tree: f-1. B [total: 50.0%, self: 50.0%] └─ ... (5 more children: combined 50.0%, max 10.0%) -f-2. D [total: 50.0%, self: 50.0%] -└─ f-3. C [total: 50.0%, self: 0.0%]" +f-7. D [total: 50.0%, self: 50.0%] +└─ f-6. C [total: 50.0%, self: 0.0%]" `; exports[`call tree formatting bottom-up view elision bugs node whose children were never expanded must still show elision marker 1`] = ` @@ -80,11 +80,11 @@ exports[`call tree formatting bottom-up view elision bugs node whose children we Thread: Test Thread Top-Down Call Tree: -f-1. Root [total: 100.0%, self: 0.0%] -├─ f-2. A [total: 60.0%, self: 0.0%] -│ ├─ f-4. A2 [total: 10.0%, self: 10.0%] +f-0. Root [total: 100.0%, self: 0.0%] +├─ f-1. A [total: 60.0%, self: 0.0%] +│ ├─ f-3. A2 [total: 10.0%, self: 10.0%] │ └─ ... (5 more children: combined 50.0%, max 10.0%) -├─ f-3. B [total: 20.0%, self: 0.0%] +├─ f-8. B [total: 20.0%, self: 0.0%] │ └─ ... (2 more children: combined 20.0%, max 10.0%) └─ ... (2 more children: combined 20.0%, max 10.0%)" `; @@ -95,11 +95,11 @@ exports[`call tree formatting bottom-up view elision bugs sibling nodes with eli Thread: Test Thread Top-Down Call Tree: -f-1. A [total: 100.0%, self: 0.0%] -├─ f-2. B1 [total: 50.0%, self: 0.0%] -│ ├─ f-4. C5 [total: 10.0%, self: 10.0%] +f-0. A [total: 100.0%, self: 0.0%] +├─ f-1. B1 [total: 50.0%, self: 0.0%] +│ ├─ f-6. C5 [total: 10.0%, self: 10.0%] │ └─ ... (4 more children: combined 40.0%, max 10.0%) -└─ f-3. B2 [total: 50.0%, self: 0.0%] +└─ f-7. B2 [total: 50.0%, self: 0.0%] └─ ... (5 more children: combined 50.0%, max 10.0%)" `; @@ -110,9 +110,9 @@ Thread: Test Thread Bottom-Up Call Tree: f-1. D [total: 28.6%, self: 28.6%] -└─ f-5. A [total: 28.6%, self: 0.0%] +└─ f-0. A [total: 28.6%, self: 0.0%] f-2. E [total: 28.6%, self: 28.6%] -└─ f-5. A [total: 28.6%, self: 0.0%] +└─ f-0. A [total: 28.6%, self: 0.0%] f-3. B [total: 28.6%, self: 28.6%] f-4. C [total: 14.3%, self: 14.3%]" `; @@ -123,10 +123,10 @@ exports[`call tree formatting bottom-up view simple trees formats a simple linea Thread: Test Thread Bottom-Up Call Tree: -f-1. D [total: 100.0%, self: 100.0%] +f-3. D [total: 100.0%, self: 100.0%] └─ f-2. C [total: 100.0%, self: 0.0%] - f-3. B [total: 100.0%, self: 0.0%] - f-4. A [total: 100.0%, self: 0.0%]" + f-1. B [total: 100.0%, self: 0.0%] + f-0. A [total: 100.0%, self: 0.0%]" `; exports[`call tree formatting bottom-up view trees with truncation shows elided callers at multiple levels 1`] = ` @@ -135,14 +135,14 @@ exports[`call tree formatting bottom-up view trees with truncation shows elided Thread: Test Thread Bottom-Up Call Tree: -f-1. E [total: 25.0%, self: 25.0%] -└─ f-5. B [total: 25.0%, self: 0.0%] - f-4. A [total: 25.0%, self: 0.0%] -f-2. F [total: 25.0%, self: 25.0%] -└─ f-5. B [total: 25.0%, self: 0.0%] - f-4. A [total: 25.0%, self: 0.0%] -f-3. D [total: 25.0%, self: 25.0%] -└─ f-4. A [total: 25.0%, self: 0.0%]" +f-2. E [total: 25.0%, self: 25.0%] +└─ f-1. B [total: 25.0%, self: 0.0%] + f-0. A [total: 25.0%, self: 0.0%] +f-3. F [total: 25.0%, self: 25.0%] +└─ f-1. B [total: 25.0%, self: 0.0%] + f-0. A [total: 25.0%, self: 0.0%] +f-7. D [total: 25.0%, self: 25.0%] +└─ f-0. A [total: 25.0%, self: 0.0%]" `; exports[`call tree formatting bottom-up view trees with truncation shows elided callers with correct percentages 1`] = ` @@ -152,9 +152,9 @@ Thread: Test Thread Bottom-Up Call Tree: f-1. B [total: 30.0%, self: 30.0%] -└─ f-4. A [total: 30.0%, self: 0.0%] +└─ f-0. A [total: 30.0%, self: 0.0%] f-2. C [total: 20.0%, self: 20.0%] -└─ f-4. A [total: 20.0%, self: 0.0%] +└─ f-0. A [total: 20.0%, self: 0.0%] f-3. D [total: 10.0%, self: 10.0%] └─ ... (1 more children: combined 10.0%, max 10.0%)" `; @@ -165,19 +165,19 @@ exports[`call tree formatting top-down view complex nested trees formats a compl Thread: Test Thread Top-Down Call Tree: -f-1. Main [total: 100.0%, self: 0.0%] +f-0. Main [total: 100.0%, self: 0.0%] ├─ f-2. Loop [total: 90.0%, self: 0.0%] -│ ├─ f-4. Tick [total: 40.0%, self: 0.0%] -│ │ ├─ f-11. AI [total: 20.0%, self: 0.0%] -│ │ │ f-13. Think [total: 20.0%, self: 20.0%] -│ │ └─ f-12. Phys [total: 20.0%, self: 20.0%] -│ ├─ f-5. Idle [total: 20.0%, self: 20.0%] -│ ├─ f-6. Render [total: 10.0%, self: 10.0%] -│ ├─ f-7. Rende [total: 10.0%, self: 0.0%] +│ ├─ f-3. Tick [total: 40.0%, self: 0.0%] +│ │ ├─ f-4. AI [total: 20.0%, self: 0.0%] +│ │ │ f-5. Think [total: 20.0%, self: 20.0%] +│ │ └─ f-6. Phys [total: 20.0%, self: 20.0%] +│ ├─ f-7. Idle [total: 20.0%, self: 20.0%] +│ ├─ f-8. Render [total: 10.0%, self: 10.0%] +│ ├─ f-9. Rende [total: 10.0%, self: 0.0%] │ │ f-10. Layou [total: 10.0%, self: 10.0%] -│ └─ f-8. r Render [total: 10.0%, self: 0.0%] -│ f-9. t Layout [total: 10.0%, self: 10.0%] -└─ f-3. Init [total: 10.0%, self: 10.0%]" +│ └─ f-11. r Render [total: 10.0%, self: 0.0%] +│ f-12. t Layout [total: 10.0%, self: 10.0%] +└─ f-1. Init [total: 10.0%, self: 10.0%]" `; exports[`call tree formatting top-down view complex nested trees formats a deep nested path with branching 1`] = ` @@ -186,15 +186,15 @@ exports[`call tree formatting top-down view complex nested trees formats a deep Thread: Test Thread Top-Down Call Tree: -f-1. A [total: 80.0%, self: 0.0%] -├─ f-3. C [total: 60.0%, self: 0.0%] -│ ├─ f-5. E [total: 40.0%, self: 0.0%] -│ │ ├─ f-7. G [total: 20.0%, self: 0.0%] -│ │ │ f-9. I [total: 20.0%, self: 20.0%] -│ │ └─ f-8. H [total: 20.0%, self: 20.0%] +f-0. A [total: 80.0%, self: 0.0%] +├─ f-1. C [total: 60.0%, self: 0.0%] +│ ├─ f-2. E [total: 40.0%, self: 0.0%] +│ │ ├─ f-3. G [total: 20.0%, self: 0.0%] +│ │ │ f-4. I [total: 20.0%, self: 20.0%] +│ │ └─ f-5. H [total: 20.0%, self: 20.0%] │ └─ f-6. F [total: 20.0%, self: 20.0%] -└─ f-4. D [total: 20.0%, self: 20.0%] -f-2. B [total: 20.0%, self: 20.0%]" +└─ f-7. D [total: 20.0%, self: 20.0%] +f-8. B [total: 20.0%, self: 20.0%]" `; exports[`call tree formatting top-down view different scoring strategies exponential-0.9 strategy output 1`] = ` @@ -203,13 +203,13 @@ exports[`call tree formatting top-down view different scoring strategies exponen Thread: Test Thread Top-Down Call Tree: -f-1. A [total: 60.0%, self: 0.0%] -├─ f-4. D [total: 20.0%, self: 0.0%] -│ f-7. G [total: 20.0%, self: 20.0%] -├─ f-5. E [total: 20.0%, self: 20.0%] -└─ f-6. F [total: 20.0%, self: 20.0%] -f-2. B [total: 20.0%, self: 20.0%] -f-3. C [total: 20.0%, self: 20.0%]" +f-0. A [total: 60.0%, self: 0.0%] +├─ f-1. D [total: 20.0%, self: 0.0%] +│ f-2. G [total: 20.0%, self: 20.0%] +├─ f-3. E [total: 20.0%, self: 20.0%] +└─ f-4. F [total: 20.0%, self: 20.0%] +f-5. B [total: 20.0%, self: 20.0%] +f-6. C [total: 20.0%, self: 20.0%]" `; exports[`call tree formatting top-down view different scoring strategies percentage-only strategy output 1`] = ` @@ -218,13 +218,13 @@ exports[`call tree formatting top-down view different scoring strategies percent Thread: Test Thread Top-Down Call Tree: -f-1. A [total: 60.0%, self: 0.0%] -├─ f-4. D [total: 20.0%, self: 0.0%] -│ f-7. G [total: 20.0%, self: 20.0%] -├─ f-5. E [total: 20.0%, self: 20.0%] -└─ f-6. F [total: 20.0%, self: 20.0%] -f-2. B [total: 20.0%, self: 20.0%] -f-3. C [total: 20.0%, self: 20.0%]" +f-0. A [total: 60.0%, self: 0.0%] +├─ f-1. D [total: 20.0%, self: 0.0%] +│ f-2. G [total: 20.0%, self: 20.0%] +├─ f-3. E [total: 20.0%, self: 20.0%] +└─ f-4. F [total: 20.0%, self: 20.0%] +f-5. B [total: 20.0%, self: 20.0%] +f-6. C [total: 20.0%, self: 20.0%]" `; exports[`call tree formatting top-down view ordering and percentages correctly calculates percentages for nested nodes 1`] = ` @@ -233,12 +233,12 @@ exports[`call tree formatting top-down view ordering and percentages correctly c Thread: Test Thread Top-Down Call Tree: -f-1. A [total: 100.0%, self: 0.0%] -├─ f-2. B [total: 60.0%, self: 10.0%] -│ ├─ f-5. E [total: 30.0%, self: 30.0%] -│ └─ f-6. F [total: 20.0%, self: 20.0%] -├─ f-3. C [total: 20.0%, self: 20.0%] -└─ f-4. D [total: 20.0%, self: 20.0%]" +f-0. A [total: 100.0%, self: 0.0%] +├─ f-1. B [total: 60.0%, self: 10.0%] +│ ├─ f-2. E [total: 30.0%, self: 30.0%] +│ └─ f-3. F [total: 20.0%, self: 20.0%] +├─ f-4. C [total: 20.0%, self: 20.0%] +└─ f-5. D [total: 20.0%, self: 20.0%]" `; exports[`call tree formatting top-down view ordering and percentages maintains correct ordering by sample count 1`] = ` @@ -247,10 +247,10 @@ exports[`call tree formatting top-down view ordering and percentages maintains c Thread: Test Thread Top-Down Call Tree: -f-1. A [total: 100.0%, self: 0.0%] -├─ f-2. B [total: 50.0%, self: 50.0%] -├─ f-3. C [total: 30.0%, self: 30.0%] -└─ f-4. D [total: 20.0%, self: 20.0%]" +f-0. A [total: 100.0%, self: 0.0%] +├─ f-1. B [total: 50.0%, self: 50.0%] +├─ f-2. C [total: 30.0%, self: 30.0%] +└─ f-3. D [total: 20.0%, self: 20.0%]" `; exports[`call tree formatting top-down view simple trees formats a branching tree 1`] = ` @@ -259,11 +259,11 @@ exports[`call tree formatting top-down view simple trees formats a branching tre Thread: Test Thread Top-Down Call Tree: -f-1. A [total: 57.1%, self: 0.0%] -├─ f-4. D [total: 28.6%, self: 28.6%] -└─ f-5. E [total: 28.6%, self: 28.6%] -f-2. B [total: 28.6%, self: 28.6%] -f-3. C [total: 14.3%, self: 14.3%]" +f-0. A [total: 57.1%, self: 0.0%] +├─ f-1. D [total: 28.6%, self: 28.6%] +└─ f-2. E [total: 28.6%, self: 28.6%] +f-3. B [total: 28.6%, self: 28.6%] +f-4. C [total: 14.3%, self: 14.3%]" `; exports[`call tree formatting top-down view simple trees formats a simple linear tree 1`] = ` @@ -272,10 +272,10 @@ exports[`call tree formatting top-down view simple trees formats a simple linear Thread: Test Thread Top-Down Call Tree: -f-1. A [total: 100.0%, self: 0.0%] -└─ f-2. B [total: 100.0%, self: 0.0%] - f-3. C [total: 100.0%, self: 0.0%] - f-4. D [total: 100.0%, self: 100.0%]" +f-0. A [total: 100.0%, self: 0.0%] +└─ f-1. B [total: 100.0%, self: 0.0%] + f-2. C [total: 100.0%, self: 0.0%] + f-3. D [total: 100.0%, self: 100.0%]" `; exports[`call tree formatting top-down view trees with truncation shows elided children at multiple levels 1`] = ` @@ -284,12 +284,12 @@ exports[`call tree formatting top-down view trees with truncation shows elided c Thread: Test Thread Top-Down Call Tree: -f-1. A [total: 100.0%, self: 0.0%] -├─ f-2. B [total: 50.0%, self: 0.0%] +f-0. A [total: 100.0%, self: 0.0%] +├─ f-1. B [total: 50.0%, self: 0.0%] │ └─ ... (2 more children: combined 50.0%, max 25.0%) -├─ f-3. C [total: 25.0%, self: 0.0%] +├─ f-4. C [total: 25.0%, self: 0.0%] │ └─ ... (2 more children: combined 25.0%, max 12.5%) -└─ f-4. D [total: 25.0%, self: 25.0%]" +└─ f-7. D [total: 25.0%, self: 25.0%]" `; exports[`call tree formatting top-down view trees with truncation shows elided children with correct percentages 1`] = ` @@ -298,8 +298,8 @@ exports[`call tree formatting top-down view trees with truncation shows elided c Thread: Test Thread Top-Down Call Tree: -f-1. A [total: 100.0%, self: 0.0%] -├─ f-2. B [total: 30.0%, self: 30.0%] +f-0. A [total: 100.0%, self: 0.0%] +├─ f-1. B [total: 30.0%, self: 30.0%] └─ ... (6 more children: combined 70.0%, max 20.0%)" `; @@ -309,10 +309,10 @@ exports[`call tree formatting top-down view trees with truncation shows truncati Thread: Test Thread Top-Down Call Tree: -f-1. A [total: 100.0%, self: 0.0%] -├─ f-2. B [total: 8.3%, self: 8.3%] -├─ f-3. I [total: 8.3%, self: 8.3%] -├─ f-4. J [total: 8.3%, self: 8.3%] -├─ f-5. K [total: 8.3%, self: 8.3%] +f-0. A [total: 100.0%, self: 0.0%] +├─ f-1. B [total: 8.3%, self: 8.3%] +├─ f-8. I [total: 8.3%, self: 8.3%] +├─ f-9. J [total: 8.3%, self: 8.3%] +├─ f-10. K [total: 8.3%, self: 8.3%] └─ ... (8 more children: combined 66.7%, max 8.3%)" `; diff --git a/profile-query-cli/src/test/unit/call-tree-formatting.test.ts b/profile-query-cli/src/test/unit/call-tree-formatting.test.ts index c471ccd4b3..23a5dce71d 100644 --- a/profile-query-cli/src/test/unit/call-tree-formatting.test.ts +++ b/profile-query-cli/src/test/unit/call-tree-formatting.test.ts @@ -2,7 +2,6 @@ * 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/. */ -import { FunctionMap } from 'firefox-profiler/profile-query/function-map'; import { collectCallTree } from 'firefox-profiler/profile-query/formatters/call-tree'; import type { ThreadSamplesTopDownResult, @@ -53,17 +52,9 @@ function buildTopDownResult( const state = store.getState(); const threadSelectors = getThreadSelectors(0); const callTree = threadSelectors.getCallTree(state); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; - const regularCallTree = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - options - ); + const regularCallTree = collectCallTree(callTree, libs, options); return { type: 'thread-samples-top-down', @@ -85,8 +76,6 @@ function buildBottomUpResult( const store = storeWithProfile(profile); const state = store.getState(); const threadSelectors = getThreadSelectors(0); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; // Build inverted call tree (bottom-up view) @@ -129,13 +118,7 @@ function buildBottomUpResult( weightType ); - collectedInvertedTree = collectCallTree( - invertedTree, - functionMap, - threadIndexes, - libs, - options - ); + collectedInvertedTree = collectCallTree(invertedTree, libs, options); } catch (e) { // Failed to create inverted tree console.error('Failed to create inverted call tree:', e); diff --git a/src/profile-query/README.md b/src/profile-query/README.md index 3386e61bf5..2df25f83b4 100644 --- a/src/profile-query/README.md +++ b/src/profile-query/README.md @@ -53,6 +53,9 @@ The library is built on top of the Firefox Profiler's Redux store and selectors: - **ProfileQuerier**: Main class that wraps a Redux store and provides query methods - **TimestampManager**: Manages timestamp naming for time range queries - **ThreadMap**: Maps thread handles (e.g., "t-0", "t-1") to thread indices +- **Function handles**: Canonical handles like `f-123` refer to shared + `profile.shared.funcTable` indices and are stable across sessions for the same + processed profile data - **Formatters**: Format query results as human-readable text All query results are returned as formatted strings, suitable for display in a terminal or log file. diff --git a/src/profile-query/formatters/call-tree.ts b/src/profile-query/formatters/call-tree.ts index 06954b8a27..356f139438 100644 --- a/src/profile-query/formatters/call-tree.ts +++ b/src/profile-query/formatters/call-tree.ts @@ -3,13 +3,9 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; -import type { - IndexIntoCallNodeTable, - ThreadIndex, - Lib, -} from 'firefox-profiler/types'; +import type { IndexIntoCallNodeTable, Lib } from 'firefox-profiler/types'; import type { CallTreeNode, CallTreeScoringStrategy } from '../types'; -import type { FunctionMap } from '../function-map'; +import { getFunctionHandle } from '../function-map'; import { formatFunctionNameWithLibrary } from '../function-list'; /** @@ -153,8 +149,6 @@ export type CallTreeCollectionOptions = { */ export function collectCallTree( tree: CallTree, - functionMap: FunctionMap, - threadIndexes: Set, libs: Lib[], options: CallTreeCollectionOptions = {} ): CallTreeNode { @@ -227,13 +221,7 @@ export function collectCallTree( } } - return buildTreeStructure( - tree, - includedNodes, - functionMap, - threadIndexes, - libs - ); + return buildTreeStructure(tree, includedNodes, libs); } /** @@ -242,8 +230,6 @@ export function collectCallTree( function buildTreeStructure( tree: CallTree, includedNodes: Set, - functionMap: FunctionMap, - threadIndexes: Set, libs: Lib[] ): CallTreeNode { // Get total sample count from the tree for percentage calculations @@ -295,7 +281,7 @@ function buildTreeStructure( const childNode: CallTreeNode = { callNodeIndex, - functionHandle: functionMap.handleForFunction(threadIndexes, funcIndex), + functionHandle: getFunctionHandle(funcIndex), functionIndex: funcIndex, name: childNodeData.funcName, nameWithLibrary, diff --git a/src/profile-query/formatters/thread-info.ts b/src/profile-query/formatters/thread-info.ts index f3af9d46a3..9733a67b67 100644 --- a/src/profile-query/formatters/thread-info.ts +++ b/src/profile-query/formatters/thread-info.ts @@ -37,7 +37,7 @@ import { getInvertedCallNodeInfo } from 'firefox-profiler/profile-logic/profile- import type { Store } from '../../types/store'; import type { TimestampManager } from '../timestamps'; import type { ThreadMap } from '../thread-map'; -import type { FunctionMap } from '../function-map'; +import { getFunctionHandle } from '../function-map'; import type { CallNodePath } from 'firefox-profiler/types'; export function formatThreadInfo( @@ -123,7 +123,6 @@ export function collectThreadInfo( export function collectThreadSamples( store: Store, threadMap: ThreadMap, - functionMap: FunctionMap, threadHandle?: string ): ThreadSamplesResult { const state = store.getState(); @@ -158,10 +157,7 @@ export function collectThreadSamples( // Convert top functions to structured format const topFunctionsByTotal: TopFunctionInfo[] = sortedByTotal.map((func) => ({ - functionHandle: functionMap.handleForFunction( - threadIndexes, - func.funcIndex - ), + functionHandle: getFunctionHandle(func.funcIndex), functionIndex: func.funcIndex, name: func.funcName, nameWithLibrary: func.funcName, // Already includes library from extractFunctionData @@ -173,10 +169,7 @@ export function collectThreadSamples( })); const topFunctionsBySelf: TopFunctionInfo[] = sortedBySelf.map((func) => ({ - functionHandle: functionMap.handleForFunction( - threadIndexes, - func.funcIndex - ), + functionHandle: getFunctionHandle(func.funcIndex), functionIndex: func.funcIndex, name: func.funcName, nameWithLibrary: func.funcName, // Already includes library from extractFunctionData @@ -267,7 +260,6 @@ export function collectThreadSamples( export function collectThreadSamplesBottomUp( store: Store, threadMap: ThreadMap, - functionMap: FunctionMap, threadHandle?: string, callTreeOptions?: CallTreeCollectionOptions ): ThreadSamplesBottomUpResult { @@ -321,15 +313,8 @@ export function collectThreadSamplesBottomUp( weightType ); - // Note: Bottom-up tree uses the same threadIndexes to generate function handles const libs = getProfile(state).libs; - invertedCallTree = collectCallTree( - invertedTree, - functionMap, - threadIndexes, - libs, - callTreeOptions - ); + invertedCallTree = collectCallTree(invertedTree, libs, callTreeOptions); } catch (_e) { // Inverted tree creation failed, leave as null } @@ -349,7 +334,6 @@ export function collectThreadSamplesBottomUp( export function collectThreadSamplesTopDown( store: Store, threadMap: ThreadMap, - functionMap: FunctionMap, threadHandle?: string, callTreeOptions?: CallTreeCollectionOptions ): ThreadSamplesTopDownResult { @@ -365,13 +349,7 @@ export function collectThreadSamplesTopDown( const libs = getProfile(state).libs; // Collect regular call tree - const regularCallTree = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - callTreeOptions - ); + const regularCallTree = collectCallTree(callTree, libs, callTreeOptions); return { type: 'thread-samples-top-down', @@ -388,7 +366,6 @@ export function collectThreadSamplesTopDown( export function collectThreadFunctions( store: Store, threadMap: ThreadMap, - functionMap: FunctionMap, threadHandle?: string, filterOptions?: FunctionFilterOptions ): ThreadFunctionsResult { @@ -475,10 +452,7 @@ export function collectThreadFunctions( } return { - functionHandle: functionMap.handleForFunction( - threadIndexes, - func.funcIndex - ), + functionHandle: getFunctionHandle(func.funcIndex), functionIndex: func.funcIndex, name, nameWithLibrary, diff --git a/src/profile-query/function-list.ts b/src/profile-query/function-list.ts index 7d3a2ce65b..cd48ebfa23 100644 --- a/src/profile-query/function-list.ts +++ b/src/profile-query/function-list.ts @@ -2,8 +2,8 @@ * 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/. */ -import type { Thread, Lib, ThreadIndex } from 'firefox-profiler/types'; -import type { FunctionMap } from './function-map'; +import type { Thread, Lib } from 'firefox-profiler/types'; +import { getFunctionHandle } from './function-map'; export type FunctionData = { funcName: string; @@ -413,9 +413,7 @@ export function sortBySelf(functions: FunctionData[]): FunctionData[] { */ function formatFunctionEntry( func: FunctionData, - sortKey: 'total' | 'self', - threadIndexes: Set, - functionMap: FunctionMap + sortKey: 'total' | 'self' ): string { const totalPct = (func.totalRelative * 100).toFixed(1); const selfPct = (func.selfRelative * 100).toFixed(1); @@ -425,8 +423,7 @@ function formatFunctionEntry( // Truncate function name to 120 characters (smart truncation preserves meaning) const displayName = truncateFunctionName(func.funcName, 120); - // Generate handle if FunctionMap is provided - const handle = functionMap.handleForFunction(threadIndexes, func.funcIndex); + const handle = getFunctionHandle(func.funcIndex); const handleStr = `${handle}. `; if (sortKey === 'total') { @@ -464,15 +461,13 @@ export function formatFunctionList( title: string, functions: FunctionData[], limit: number, - sortKey: 'total' | 'self', - threadIndexes: Set, - functionMap: FunctionMap + sortKey: 'total' | 'self' ): FormattedFunctionList { const displayedFunctions = functions.slice(0, limit); const omittedFunctions = functions.slice(limit); const lines = displayedFunctions.map((func) => - formatFunctionEntry(func, sortKey, threadIndexes, functionMap) + formatFunctionEntry(func, sortKey) ); const stats = computeOmittedStats(omittedFunctions); @@ -499,26 +494,20 @@ export function formatFunctionList( */ export function createTopFunctionLists( functions: FunctionData[], - limit: number, - threadIndexes: Set, - functionMap: FunctionMap + limit: number ): { byTotal: FormattedFunctionList; bySelf: FormattedFunctionList } { const byTotal = formatFunctionList( 'Top Functions (by total time)', sortByTotal(functions), limit, - 'total', - threadIndexes, - functionMap + 'total' ); const bySelf = formatFunctionList( 'Top Functions (by self time)', sortBySelf(functions), limit, - 'self', - threadIndexes, - functionMap + 'self' ); return { byTotal, bySelf }; diff --git a/src/profile-query/function-map.ts b/src/profile-query/function-map.ts index ef002c04a0..0b48685a92 100644 --- a/src/profile-query/function-map.ts +++ b/src/profile-query/function-map.ts @@ -2,68 +2,34 @@ * 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/. */ -import { getThreadsKey } from 'firefox-profiler/profile-logic/profile-data'; -import type { - ThreadIndex, - IndexIntoFuncTable, - ThreadsKey, -} from 'firefox-profiler/types'; +import type { IndexIntoFuncTable } from 'firefox-profiler/types'; /** - * Represents a function identified by its thread and function index. + * A handle like "f-123" always refers to funcTable index 123 for this profile, + * making handles stable across sessions for the same processed profile data. */ -export type FunctionId = { - threadIndexes: Set; - threadsKey: ThreadsKey; - funcIndex: IndexIntoFuncTable; -}; +export function getFunctionHandle( + funcIndex: IndexIntoFuncTable +): `f-${number}` { + return `f-${funcIndex}`; +} /** - * Maps function handles (like "f-1", "f-2") to (threadIndex, funcIndex) pairs. - * This provides a user-friendly way to reference functions in the CLI. - * - * Since each thread has its own funcTable, we need to store both the thread - * index and the function index to uniquely identify a function. + * Parse a function handle and validate it against the shared funcTable length. */ -export class FunctionMap { - _handleToFunction: Map = new Map(); - _nextHandleId: number = 1; - - /** - * Get or create a handle for a function. - * Returns the same handle if called multiple times with the same function. - */ - handleForFunction( - threadIndexes: Set, - funcIndex: IndexIntoFuncTable - ): string { - // Check if we already have a handle for this function - const threadsKey = getThreadsKey(threadIndexes); - for (const [handle, funcId] of this._handleToFunction.entries()) { - if (funcId.threadsKey === threadsKey && funcId.funcIndex === funcIndex) { - return handle; - } - } - - // Create a new handle - const handle = 'f-' + this._nextHandleId++; - this._handleToFunction.set(handle, { - threadIndexes, - threadsKey, - funcIndex, - }); - return handle; +export function parseFunctionHandle( + functionHandle: string, + funcCount: number +): IndexIntoFuncTable { + const match = /^f-(\d+)$/.exec(functionHandle); + if (match === null) { + throw new Error(`Unknown function ${functionHandle}`); } - /** - * Look up a function by its handle. - * Throws an error if the handle is unknown. - */ - functionForHandle(functionHandle: string): FunctionId { - const funcId = this._handleToFunction.get(functionHandle); - if (funcId === undefined) { - throw new Error(`Unknown function ${functionHandle}`); - } - return funcId; + const funcIndex = Number(match[1]); + if (!Number.isInteger(funcIndex) || funcIndex < 0 || funcIndex >= funcCount) { + throw new Error(`Unknown function ${functionHandle}`); } + + return funcIndex; } diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 0a13b24840..72e0e97d32 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -38,7 +38,7 @@ import { import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { TimestampManager } from './timestamps'; import { ThreadMap } from './thread-map'; -import { FunctionMap } from './function-map'; +import { parseFunctionHandle } from './function-map'; import { MarkerMap } from './marker-map'; import { loadProfileFromFileOrUrl } from './loader'; import { collectProfileInfo } from './formatters/profile-info'; @@ -85,7 +85,6 @@ export class ProfileQuerier { _processIndexMap: Map; _timestampManager: TimestampManager; _threadMap: ThreadMap; - _functionMap: FunctionMap; _markerMap: MarkerMap; constructor(store: Store, rootRange: StartEndRange) { @@ -93,12 +92,11 @@ export class ProfileQuerier { this._processIndexMap = new Map(); this._timestampManager = new TimestampManager(rootRange); this._threadMap = new ThreadMap(); - this._functionMap = new FunctionMap(); - this._markerMap = new MarkerMap(); // Build process index map const state = this._store.getState(); const profile = getProfile(state); + this._markerMap = new MarkerMap(); const uniquePids = Array.from(new Set(profile.threads.map((t) => t.pid))); uniquePids.forEach((pid, index) => { this._processIndexMap.set(pid, index); @@ -148,12 +146,7 @@ export class ProfileQuerier { ? this._threadMap.threadIndexesForHandle(threadHandle) : getSelectedThreadIndexes(this._store.getState()); const collect = () => - collectThreadSamples( - this._store, - this._threadMap, - this._functionMap, - threadHandle - ); + collectThreadSamples(this._store, this._threadMap, threadHandle); const withIdle = activeOnly ? () => this._withDroppedIdle(threadIndexes, collect) : collect; @@ -183,7 +176,6 @@ export class ProfileQuerier { collectThreadSamplesTopDown( this._store, this._threadMap, - this._functionMap, threadHandle, callTreeOptions ); @@ -216,7 +208,6 @@ export class ProfileQuerier { collectThreadSamplesBottomUp( this._store, this._threadMap, - this._functionMap, threadHandle, callTreeOptions ); @@ -620,22 +611,17 @@ export class ProfileQuerier { ): Promise> { const state = this._store.getState(); const profile = getProfile(state); + const { funcTable, resourceTable, stringArray } = profile.shared; // Look up the function - const { threadIndexes, funcIndex } = - this._functionMap.functionForHandle(functionHandle); - - const threadSelectors = getThreadSelectors(threadIndexes); - const thread = threadSelectors.getFilteredThread(state); - const funcName = thread.stringTable.getString( - thread.funcTable.name[funcIndex] - ); - const resourceIndex = thread.funcTable.resource[funcIndex]; + const funcIndex = parseFunctionHandle(functionHandle, funcTable.length); + const funcName = stringArray[funcTable.name[funcIndex]]; + const resourceIndex = funcTable.resource[funcIndex]; // Get library prefix if available let library: string | undefined; - if (resourceIndex !== -1 && thread.resourceTable) { - const libIndex = thread.resourceTable.lib[resourceIndex]; + if (resourceIndex !== -1) { + const libIndex = resourceTable.lib[resourceIndex]; if (libIndex !== null && libIndex !== undefined && profile.libs) { const lib = profile.libs[libIndex]; library = lib.name; @@ -643,13 +629,11 @@ export class ProfileQuerier { } const fullName = library ? `${library}!${funcName}` : funcName; - const threadHandle = this._threadMap.handleForThreadIndexes(threadIndexes); return { type: 'function-expand', functionHandle, funcIndex, - threadHandle, name: funcName, fullName, library, @@ -665,37 +649,28 @@ export class ProfileQuerier { ): Promise> { const state = this._store.getState(); const profile = getProfile(state); + const { funcTable, resourceTable, stringArray } = profile.shared; // Look up the function - const { threadIndexes, funcIndex } = - this._functionMap.functionForHandle(functionHandle); - - const threadSelectors = getThreadSelectors(threadIndexes); - const thread = threadSelectors.getFilteredThread(state); - const threadHandle = this._threadMap.handleForThreadIndexes(threadIndexes); - - const funcName = thread.stringTable.getString( - thread.funcTable.name[funcIndex] - ); - const resourceIndex = thread.funcTable.resource[funcIndex]; - const isJS = thread.funcTable.isJS[funcIndex]; - const relevantForJS = thread.funcTable.relevantForJS[funcIndex]; + const funcIndex = parseFunctionHandle(functionHandle, funcTable.length); + const funcName = stringArray[funcTable.name[funcIndex]]; + const resourceIndex = funcTable.resource[funcIndex]; + const isJS = funcTable.isJS[funcIndex]; + const relevantForJS = funcTable.relevantForJS[funcIndex]; let resource: FunctionInfoResult['resource']; let library: FunctionInfoResult['library']; let libraryName: string | undefined; // Add resource info if available - if (resourceIndex !== -1 && thread.resourceTable) { - const resourceName = thread.stringTable.getString( - thread.resourceTable.name[resourceIndex] - ); + if (resourceIndex !== -1) { + const resourceName = stringArray[resourceTable.name[resourceIndex]]; resource = { name: resourceName, index: resourceIndex, }; - const libIndex = thread.resourceTable.lib[resourceIndex]; + const libIndex = resourceTable.lib[resourceIndex]; if ( libIndex !== null && libIndex !== undefined && @@ -720,8 +695,6 @@ export class ProfileQuerier { type: 'function-info', functionHandle, funcIndex, - threadHandle, - threadName: thread.name, name: funcName, fullName, isJS, @@ -767,7 +740,6 @@ export class ProfileQuerier { collectThreadFunctions( this._store, this._threadMap, - this._functionMap, threadHandle, filterOptions ); diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 68bf081265..fcb74bbe0f 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -95,7 +95,6 @@ export type FunctionExpandResult = { type: 'function-expand'; functionHandle: string; funcIndex: number; - threadHandle: string; name: string; fullName: string; library?: string; @@ -105,8 +104,6 @@ export type FunctionInfoResult = { type: 'function-info'; functionHandle: string; funcIndex: number; - threadHandle: string; - threadName: string; name: string; fullName: string; isJS: boolean; diff --git a/src/test/unit/profile-query/call-tree.test.ts b/src/test/unit/profile-query/call-tree.test.ts index e62d5187e3..41dbbf43b1 100644 --- a/src/test/unit/profile-query/call-tree.test.ts +++ b/src/test/unit/profile-query/call-tree.test.ts @@ -2,7 +2,6 @@ * 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/. */ -import { FunctionMap } from '../../../profile-query/function-map'; import { collectCallTree } from '../../../profile-query/formatters/call-tree'; import type { CallTreeNode } from '../../../profile-query/types'; import { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; @@ -24,20 +23,12 @@ describe('call-tree collection', function () { const state = store.getState(); const threadSelectors = getThreadSelectors(0); const callTree = threadSelectors.getCallTree(state); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; // Collect with budget of 3 nodes - const result = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - { - maxNodes: 3, - } - ); + const result = collectCallTree(callTree, libs, { + maxNodes: 3, + }); // Count nodes (excluding virtual root) const nodeCount = countNodes(result) - 1; @@ -56,20 +47,12 @@ describe('call-tree collection', function () { const state = store.getState(); const threadSelectors = getThreadSelectors(0); const callTree = threadSelectors.getCallTree(state); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; // With small budget, should still include D (100% at depth 3) - const result = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - { - maxNodes: 4, - } - ); + const result = collectCallTree(callTree, libs, { + maxNodes: 4, + }); // Should include: A, B, C, D const nodeNames = collectNodeNames(result); @@ -92,20 +75,12 @@ describe('call-tree collection', function () { const state = store.getState(); const threadSelectors = getThreadSelectors(0); const callTree = threadSelectors.getCallTree(state); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; // With budget of 4: should get A, B (50%), D (50%), C (50%) - const result = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - { - maxNodes: 4, - } - ); + const result = collectCallTree(callTree, libs, { + maxNodes: 4, + }); const nodeNames = collectNodeNames(result); expect(nodeNames).toContain('A'); @@ -124,20 +99,12 @@ describe('call-tree collection', function () { const state = store.getState(); const threadSelectors = getThreadSelectors(0); const callTree = threadSelectors.getCallTree(state); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; // With budget of 2: A and B, should show C/D/E as elided - const result = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - { - maxNodes: 2, - } - ); + const result = collectCallTree(callTree, libs, { + maxNodes: 2, + }); const aNode = result.children[0]; expect(aNode.name).toBe('A'); @@ -158,20 +125,12 @@ describe('call-tree collection', function () { const state = store.getState(); const threadSelectors = getThreadSelectors(0); const callTree = threadSelectors.getCallTree(state); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; - const result = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - { - maxNodes: 4, - scoringStrategy: 'exponential-0.9', - } - ); + const result = collectCallTree(callTree, libs, { + maxNodes: 4, + scoringStrategy: 'exponential-0.9', + }); const nodeNames = collectNodeNames(result); expect(nodeNames).toContain('A'); // 66% at depth 0 @@ -190,20 +149,12 @@ describe('call-tree collection', function () { const state = store.getState(); const threadSelectors = getThreadSelectors(0); const callTree = threadSelectors.getCallTree(state); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; - const result = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - { - maxNodes: 4, - scoringStrategy: 'percentage-only', - } - ); + const result = collectCallTree(callTree, libs, { + maxNodes: 4, + scoringStrategy: 'percentage-only', + }); // All nodes should have same priority (100%), so all included const nodeCount = countNodes(result) - 1; @@ -224,20 +175,12 @@ describe('call-tree collection', function () { const state = store.getState(); const threadSelectors = getThreadSelectors(0); const callTree = threadSelectors.getCallTree(state); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; - const result = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - { - maxNodes: 10, - scoringStrategy: 'exponential-0.9', - } - ); + const result = collectCallTree(callTree, libs, { + maxNodes: 10, + scoringStrategy: 'exponential-0.9', + }); const nodeNames = collectNodeNames(result); // Should include high-percentage nodes @@ -260,20 +203,12 @@ describe('call-tree collection', function () { const state = store.getState(); const threadSelectors = getThreadSelectors(0); const callTree = threadSelectors.getCallTree(state); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; // Small budget to force truncation - const result = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - { - maxNodes: 3, - } - ); + const result = collectCallTree(callTree, libs, { + maxNodes: 3, + }); const aNode = result.children[0]; expect(aNode.name).toBe('A'); @@ -302,8 +237,6 @@ describe('call-tree collection', function () { const state = store.getState(); const threadSelectors = getThreadSelectors(0); const callTree = threadSelectors.getCallTree(state); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; // First verify that A has many children @@ -313,16 +246,10 @@ describe('call-tree collection', function () { const aChildren = callTree.getChildren(aCallNode); expect(aChildren.length).toBe(16); // B through Q - const result = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - { - maxNodes: 5, // Small budget to ensure truncation - maxChildrenPerNode: 10, - } - ); + const result = collectCallTree(callTree, libs, { + maxNodes: 5, // Small budget to ensure truncation + maxChildrenPerNode: 10, + }); const aNode = result.children[0]; expect(aNode.name).toBe('A'); @@ -345,19 +272,11 @@ describe('call-tree collection', function () { const state = store.getState(); const threadSelectors = getThreadSelectors(0); const callTree = threadSelectors.getCallTree(state); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; - const result = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - { - maxNodes: 10, - } - ); + const result = collectCallTree(callTree, libs, { + maxNodes: 10, + }); const aNode = result.children[0]; expect(aNode.name).toBe('A'); @@ -383,20 +302,12 @@ describe('call-tree collection', function () { const state = store.getState(); const threadSelectors = getThreadSelectors(0); const callTree = threadSelectors.getCallTree(state); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; - const result = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - { - maxNodes: 8, - scoringStrategy: 'exponential-0.9', - } - ); + const result = collectCallTree(callTree, libs, { + maxNodes: 8, + scoringStrategy: 'exponential-0.9', + }); const nodeNames = collectNodeNames(result); // Should include deep path A->D->E->F->G even though it's deep @@ -420,20 +331,12 @@ describe('call-tree collection', function () { const state = store.getState(); const threadSelectors = getThreadSelectors(0); const callTree = threadSelectors.getCallTree(state); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; - const result = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - { - maxNodes: 100, - maxDepth: 20, - } - ); + const result = collectCallTree(callTree, libs, { + maxNodes: 100, + maxDepth: 20, + }); const maxDepth = findMaxDepth(result); expect(maxDepth).toBeLessThanOrEqual(20); @@ -451,20 +354,12 @@ describe('call-tree collection', function () { const state = store.getState(); const threadSelectors = getThreadSelectors(0); const callTree = threadSelectors.getCallTree(state); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; // Budget that includes A and B, but not the other children - const result = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - { - maxNodes: 2, - } - ); + const result = collectCallTree(callTree, libs, { + maxNodes: 2, + }); const aNode = result.children[0]; expect(aNode.name).toBe('A'); @@ -497,19 +392,11 @@ describe('call-tree collection', function () { const state = store.getState(); const threadSelectors = getThreadSelectors(0); const callTree = threadSelectors.getCallTree(state); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; - const result = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - { - maxNodes: 2, - } - ); + const result = collectCallTree(callTree, libs, { + maxNodes: 2, + }); const aNode = result.children[0]; const bNode = aNode.children[0]; @@ -535,20 +422,12 @@ describe('call-tree collection', function () { const state = store.getState(); const threadSelectors = getThreadSelectors(0); const callTree = threadSelectors.getCallTree(state); - const functionMap = new FunctionMap(); - const threadIndexes = new Set([0]); const libs = profile.libs; - const result = collectCallTree( - callTree, - functionMap, - threadIndexes, - libs, - { - maxNodes: 1000, // High budget - maxDepth: 10, // But limited depth - } - ); + const result = collectCallTree(callTree, libs, { + maxNodes: 1000, // High budget + maxDepth: 10, // But limited depth + }); const maxDepthFound = findMaxDepth(result); expect(maxDepthFound).toBeLessThanOrEqual(10); diff --git a/src/test/unit/profile-query/function-list.test.ts b/src/test/unit/profile-query/function-list.test.ts index 9f3974ffe5..7cb3eb0bf6 100644 --- a/src/test/unit/profile-query/function-list.test.ts +++ b/src/test/unit/profile-query/function-list.test.ts @@ -2,7 +2,6 @@ * 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/. */ -import { FunctionMap } from 'firefox-profiler/profile-query/function-map'; import { extractFunctionData, sortByTotal, @@ -181,9 +180,7 @@ describe('function-list', function () { 'Top Functions', functions, 10, - 'total', - new Set([0]), - new FunctionMap() + 'total' ); expect(result.title).toBe('Top Functions'); @@ -238,14 +235,7 @@ describe('function-list', function () { }, ]; - const result = formatFunctionList( - 'Top Functions', - functions, - 3, - 'self', - new Set([0]), - new FunctionMap() - ); + const result = formatFunctionList('Top Functions', functions, 3, 'self'); expect(result.title).toBe('Top Functions'); expect(result.lines.length).toBe(5); // 3 functions + blank line + stats line @@ -278,9 +268,7 @@ describe('function-list', function () { 'Top Functions', functions, 10, - 'total', - new Set([0]), - new FunctionMap() + 'total' ); expect(result.lines[0]).toMatch(/total:.*self:/); @@ -300,14 +288,7 @@ describe('function-list', function () { }, ]; - const result = formatFunctionList( - 'Top Functions', - functions, - 10, - 'self', - new Set([0]), - new FunctionMap() - ); + const result = formatFunctionList('Top Functions', functions, 10, 'self'); expect(result.lines[0]).toMatch(/self:.*total:/); expect(result.lines[0]).toContain('self: 50 (25.0%)'); @@ -344,12 +325,7 @@ describe('function-list', function () { }, ]; - const result = createTopFunctionLists( - functions, - 10, - new Set([0]), - new FunctionMap() - ); + const result = createTopFunctionLists(functions, 10); expect(result.byTotal.title).toBe('Top Functions (by total time)'); expect(result.bySelf.title).toBe('Top Functions (by self time)'); @@ -393,12 +369,7 @@ describe('function-list', function () { }, ]; - const result = createTopFunctionLists( - functions, - 2, - new Set([0]), - new FunctionMap() - ); + const result = createTopFunctionLists(functions, 2); // Each list should have 2 functions + blank + stats = 4 lines expect(result.byTotal.lines.length).toBe(4); From ad9e4d46231fbb18f27d219f8fae3960a76f2f73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Sat, 11 Apr 2026 12:49:58 +0200 Subject: [PATCH 23/63] Update filter-samples transform to include more filtering options Previously only filtering by marker was possible. This patch adds some more filtering options that will only be used in the cli for now. They are not exposed in the profiler web frontend. --- src/profile-logic/transforms.ts | 285 ++++++++++++++++++++++++------ src/test/store/transforms.test.ts | 207 ++++++++++++++++++++++ src/types/transforms.ts | 27 ++- 3 files changed, 459 insertions(+), 60 deletions(-) diff --git a/src/profile-logic/transforms.ts b/src/profile-logic/transforms.ts index 783a5420b5..191608cfcd 100644 --- a/src/profile-logic/transforms.ts +++ b/src/profile-logic/transforms.ts @@ -334,6 +334,15 @@ export function parseTransforms(transformString: string): TransformStack { const filterString = filter.join('-'); const filterType = convertToFullFilterType(shortFilterType); + if (filterType !== 'marker-search') { + // pq-only filter types are not supported in the frontend. + console.error( + 'A pq-only filter-samples type was found in the URL and will be ignored.', + filterType + ); + break; + } + transforms.push({ type: 'filter-samples', filterType, @@ -355,6 +364,15 @@ function convertToFullFilterType(shortFilterType: string): FilterSamplesType { switch (shortFilterType) { case 'm': return 'marker-search'; + // pq-only types: + case 'om': + return 'outside-marker'; + case 'fi': + return 'function-include'; + case 'sp': + return 'stack-prefix'; + case 'ss': + return 'stack-suffix'; default: throw new Error('Unknown filter type.'); } @@ -367,6 +385,15 @@ function convertToShortFilterType(filterType: FilterSamplesType): string { switch (filterType) { case 'marker-search': return 'm'; + // pq-only types: + case 'outside-marker': + return 'om'; + case 'function-include': + return 'fi'; + case 'stack-prefix': + return 'sp'; + case 'stack-suffix': + return 'ss'; default: throw assertExhaustiveCheck(filterType); } @@ -485,6 +512,14 @@ export function getTransformLabelL10nIds( 'TransformNavigator--drop-samples-outside-of-markers-matching', item: transform.filter, }; + // pq-only filter types: + case 'outside-marker': + case 'function-include': + case 'stack-prefix': + case 'stack-suffix': + throw new Error( + `getTransformLabelL10nIds: pq-only filter type "${transform.filterType}" is not supported in the frontend transform navigator.` + ); default: throw assertExhaustiveCheck(transform.filterType); } @@ -1795,70 +1830,169 @@ export function filterSamples( filter: string ): Thread { return timeCode('filterSamples', () => { - // Find the ranges to filter. - function getFilterRanges(): StartEndRange[] { - switch (filterType) { - case 'marker-search': - return _findRangesByMarkerFilter( + const { stackTable, frameTable } = thread; + + switch (filterType) { + case 'function-include': { + // Keep only samples whose stack contains at least one of the given functions. + // The filter string is comma-separated funcIndexes. + if (!filter) { + throw new Error( + 'function-include filter requires a non-empty filter string.' + ); + } + const funcIndexes = new Set(filter.split(',').map(Number)); + // stackHasFunc[i] = 1 if stack i or any of its prefixes contains one of the functions. + const stackHasFunc = new Uint8Array(stackTable.length); + for (let i = 0; i < stackTable.length; i++) { + const prefix = stackTable.prefix[i]; + const f = frameTable.func[stackTable.frame[i]]; + if (funcIndexes.has(f) || (prefix !== null && stackHasFunc[prefix])) { + stackHasFunc[i] = 1; + } + } + return updateThreadStacks(thread, stackTable, (stack) => + stack !== null && !stackHasFunc[stack] ? null : stack + ); + } + + case 'stack-suffix': { + // Keep only samples whose leaf frame (the sample's direct stack) is the given function. + // The filter string is a single funcIndex. + if (!filter) { + throw new Error( + 'stack-suffix filter requires a non-empty filter string.' + ); + } + const targetFunc = Number(filter); + return updateThreadStacks(thread, stackTable, (stack) => { + if (stack === null) { + return null; + } + return frameTable.func[stackTable.frame[stack]] === targetFunc + ? stack + : null; + }); + } + + case 'stack-prefix': { + // Keep only samples whose stack starts with the given root-first sequence of functions. + // The filter string is comma-separated funcIndexes (root frame first). + if (!filter) { + throw new Error( + 'stack-prefix filter requires a non-empty filter string.' + ); + } + const prefixFuncs = filter.split(',').map(Number); + // matchDepth[i]: -1 = no match started; 1..N = number of prefix levels matched so far. + // When matchDepth[i] >= prefixFuncs.length the full prefix is matched and all + // descendants are valid. + const matchDepth = new Int32Array(stackTable.length).fill(-1); + for (let i = 0; i < stackTable.length; i++) { + const prefix = stackTable.prefix[i]; + const f = frameTable.func[stackTable.frame[i]]; + if (prefix === null) { + // Root frame: must match the first element of the prefix. + if (f === prefixFuncs[0]) { + matchDepth[i] = 1; + } + } else { + const pd = matchDepth[prefix]; + if (pd < 0) { + // Parent did not start matching — skip. + } else if (pd >= prefixFuncs.length) { + // Parent already fully matched the prefix — all descendants are valid. + matchDepth[i] = pd; + } else if (f === prefixFuncs[pd]) { + matchDepth[i] = pd + 1; + } + } + } + return updateThreadStacks(thread, stackTable, (stack) => + stack !== null && matchDepth[stack] < prefixFuncs.length + ? null + : stack + ); + } + + case 'marker-search': + case 'outside-marker': { + // Range-based filters: keep samples within (marker-search) or outside + // (outside-marker) the time ranges of matching markers. + const markerRanges = canonicalizeRangeSet( + _findRangesByMarkerFilter( getMarker, markerIndexes, markerSchemaByName, thread.stringTable, categoryList, filter - ); - default: - throw assertExhaustiveCheck(filterType); - } - } - - const ranges = canonicalizeRangeSet(getFilterRanges()); - - function computeFilteredStackColumn( - originalStackColumn: Array, - timeColumn: Milliseconds[] - ): Array { - const newStackColumn = originalStackColumn.slice(); - - // Walk the ranges and samples in order. Both are sorted by time. - // For each range, drop the samples before the range and skip the samples - // inside the range. - let sampleIndex = 0; - const sampleCount = timeColumn.length; - for (const range of ranges) { - const { start: rangeStart, end: rangeEnd } = range; - // Drop samples before the range. - for (; sampleIndex < sampleCount; sampleIndex++) { - if (timeColumn[sampleIndex] >= rangeStart) { - break; + ) + ); + const keepInsideRanges = filterType === 'marker-search'; + + function computeFilteredStackColumn( + originalStackColumn: Array, + timeColumn: Milliseconds[] + ): Array { + const newStackColumn = originalStackColumn.slice(); + let sampleIndex = 0; + const sampleCount = timeColumn.length; + + if (keepInsideRanges) { + // Keep samples INSIDE ranges; drop everything else. + for (const range of markerRanges) { + const { start: rangeStart, end: rangeEnd } = range; + for (; sampleIndex < sampleCount; sampleIndex++) { + if (timeColumn[sampleIndex] >= rangeStart) { + break; + } + newStackColumn[sampleIndex] = null; + } + for (; sampleIndex < sampleCount; sampleIndex++) { + if (timeColumn[sampleIndex] >= rangeEnd) { + break; + } + } + } + while (sampleIndex < sampleCount) { + newStackColumn[sampleIndex] = null; + sampleIndex++; + } + } else { + // Keep samples OUTSIDE ranges; drop samples inside each range. + for (const range of markerRanges) { + const { start: rangeStart, end: rangeEnd } = range; + for (; sampleIndex < sampleCount; sampleIndex++) { + if (timeColumn[sampleIndex] >= rangeStart) { + break; + } + } + for (; sampleIndex < sampleCount; sampleIndex++) { + if (timeColumn[sampleIndex] >= rangeEnd) { + break; + } + newStackColumn[sampleIndex] = null; + } + } + // Remaining samples after the last range are kept (they are outside all ranges). } - newStackColumn[sampleIndex] = null; - } - // Skip over samples inside the range. - for (; sampleIndex < sampleCount; sampleIndex++) { - if (timeColumn[sampleIndex] >= rangeEnd) { - break; - } + return newStackColumn; } - } - // Drop the remaining samples, i.e. the samples after the last range. - while (sampleIndex < sampleCount) { - newStackColumn[sampleIndex] = null; - sampleIndex++; + return updateThreadStacksByGeneratingNewStackColumns( + thread, + thread.stackTable, + computeFilteredStackColumn, + computeFilteredStackColumn, + (markerData) => markerData + ); } - return newStackColumn; + default: + throw assertExhaustiveCheck(filterType); } - - return updateThreadStacksByGeneratingNewStackColumns( - thread, - thread.stackTable, - computeFilteredStackColumn, - computeFilteredStackColumn, - (markerData) => markerData - ); }); } @@ -2134,9 +2268,54 @@ export function translateTransform( } case 'filter-samples': { switch (transform.filterType) { - case 'marker-search': { - // This transform doesn't contain any data which needs to be translated. + case 'marker-search': + case 'outside-marker': + // These filter by marker name string — no indices to remap. return transform; + case 'stack-suffix': { + // Single funcIndex encoded as a decimal string. + const newFuncIndex = translateFuncIndex( + Number(transform.filter), + translationMaps + ); + if (newFuncIndex === null) { + return null; + } + return { ...transform, filter: String(newFuncIndex) }; + } + case 'stack-prefix': { + // Comma-separated funcIndexes (root-first). The entire prefix is + // invalid if any element is missing after translation. + const translated = []; + for (const raw of transform.filter.split(',')) { + const newFuncIndex = translateFuncIndex( + Number(raw), + translationMaps + ); + if (newFuncIndex === null) { + return null; + } + translated.push(newFuncIndex); + } + return { ...transform, filter: translated.join(',') }; + } + case 'function-include': { + // Comma-separated funcIndexes. Drop missing ones; if all are gone, + // drop the transform. + const translated = []; + for (const raw of transform.filter.split(',')) { + const newFuncIndex = translateFuncIndex( + Number(raw), + translationMaps + ); + if (newFuncIndex !== null) { + translated.push(newFuncIndex); + } + } + if (translated.length === 0) { + return null; + } + return { ...transform, filter: translated.join(',') }; } default: throw assertExhaustiveCheck(transform.filterType); diff --git a/src/test/store/transforms.test.ts b/src/test/store/transforms.test.ts index 28d591d96e..ee3bf0198e 100644 --- a/src/test/store/transforms.test.ts +++ b/src/test/store/transforms.test.ts @@ -2351,6 +2351,213 @@ describe('"filter-samples" transform', function () { dispatch(popTransformsFromStack(0)); }); }); + + describe('outside-marker filter type', function () { + // Same sample layout as the marker-search tests above: + // t=0: A→B→C t=1: A→B→C→D t=2: A→C t=3: A→B→E t=4: A→F + const { profile } = getProfileFromTextSamples(` + A A A A A + B B C B F + C C E + D + `); + const threadIndex = 0; + addMarkersToThreadWithCorrespondingSamples( + profile.threads[threadIndex], + profile.shared, + [ + [ + 'DOMEvent', + 0, + 0.5, + { type: 'DOMEvent', latency: 7, eventType: 'click' }, + ], + [ + 'UserTiming', + 1.5, + 2.5, + { type: 'UserTiming', name: 'measure-2', entryType: 'measure' }, + ], + [ + 'UserTiming', + 2.5, + 3.5, + { type: 'UserTiming', name: 'measure-2', entryType: 'measure' }, + ], + ] + ); + const { dispatch, getState } = storeWithProfile(profile); + + it('keeps samples outside a single marker range', function () { + dispatch( + addTransformToStack(threadIndex, { + type: 'filter-samples', + filterType: 'outside-marker', + filter: 'DOMEvent', + }) + ); + // t=0 is inside DOMEvent (0–0.5); t=1,2,3,4 are kept. + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([ + '- A (total: 4, self: —)', + ' - B (total: 2, self: —)', + ' - C (total: 1, self: —)', + ' - D (total: 1, self: 1)', + ' - E (total: 1, self: 1)', + ' - C (total: 1, self: 1)', + ' - F (total: 1, self: 1)', + ]); + dispatch(popTransformsFromStack(0)); + }); + + it('keeps samples outside multiple marker ranges', function () { + dispatch( + addTransformToStack(threadIndex, { + type: 'filter-samples', + filterType: 'outside-marker', + filter: 'UserTiming', + }) + ); + // t=2 and t=3 are inside UserTiming ranges; t=0,1,4 are kept. + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([ + '- A (total: 3, self: —)', + ' - B (total: 2, self: —)', + ' - C (total: 2, self: 1)', + ' - D (total: 1, self: 1)', + ' - F (total: 1, self: 1)', + ]); + dispatch(popTransformsFromStack(0)); + }); + }); + + describe('function-include filter type', function () { + const { + profile, + funcNamesPerThread: [funcNames], + } = getProfileFromTextSamples(` + A A A A A + B B C B F + C C E + D + `); + const threadIndex = 0; + const { dispatch, getState } = storeWithProfile(profile); + + it('keeps only samples whose stack contains the specified function', function () { + const B = funcNames.indexOf('B'); + dispatch( + addTransformToStack(threadIndex, { + type: 'filter-samples', + filterType: 'function-include', + filter: String(B), + }) + ); + // t=0 (A→B→C), t=1 (A→B→C→D), t=3 (A→B→E) contain B; t=2 and t=4 do not. + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([ + '- A (total: 3, self: —)', + ' - B (total: 3, self: —)', + ' - C (total: 2, self: 1)', + ' - D (total: 1, self: 1)', + ' - E (total: 1, self: 1)', + ]); + dispatch(popTransformsFromStack(0)); + }); + + it('keeps samples containing any of the specified functions', function () { + const B = funcNames.indexOf('B'); + const F = funcNames.indexOf('F'); + dispatch( + addTransformToStack(threadIndex, { + type: 'filter-samples', + filterType: 'function-include', + filter: `${B},${F}`, + }) + ); + // t=0,1,3 contain B; t=4 contains F; t=2 is dropped. + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([ + '- A (total: 4, self: —)', + ' - B (total: 3, self: —)', + ' - C (total: 2, self: 1)', + ' - D (total: 1, self: 1)', + ' - E (total: 1, self: 1)', + ' - F (total: 1, self: 1)', + ]); + dispatch(popTransformsFromStack(0)); + }); + }); + + describe('stack-prefix filter type', function () { + const { + profile, + funcNamesPerThread: [funcNames], + } = getProfileFromTextSamples(` + A A A A A + B B C B F + C C E + D + `); + const threadIndex = 0; + const { dispatch, getState } = storeWithProfile(profile); + + it('keeps only samples whose stack starts with the specified prefix', function () { + const A = funcNames.indexOf('A'); + const B = funcNames.indexOf('B'); + dispatch( + addTransformToStack(threadIndex, { + type: 'filter-samples', + filterType: 'stack-prefix', + filter: `${A},${B}`, + }) + ); + // t=0 (A→B→C), t=1 (A→B→C→D), t=3 (A→B→E) start with A→B; t=2 and t=4 do not. + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([ + '- A (total: 3, self: —)', + ' - B (total: 3, self: —)', + ' - C (total: 2, self: 1)', + ' - D (total: 1, self: 1)', + ' - E (total: 1, self: 1)', + ]); + dispatch(popTransformsFromStack(0)); + }); + }); + + describe('stack-suffix filter type', function () { + const { + profile, + funcNamesPerThread: [funcNames], + } = getProfileFromTextSamples(` + A A A A A + B B C B F + C C E + D + `); + const threadIndex = 0; + const { dispatch, getState } = storeWithProfile(profile); + + it('keeps only samples whose leaf frame is the specified function', function () { + const C = funcNames.indexOf('C'); + dispatch( + addTransformToStack(threadIndex, { + type: 'filter-samples', + filterType: 'stack-suffix', + filter: String(C), + }) + ); + // t=0 (A→B→C) and t=2 (A→C) have C as their leaf; the rest do not. + const callTree = selectedThreadSelectors.getCallTree(getState()); + expect(formatTree(callTree)).toEqual([ + '- A (total: 2, self: —)', + ' - B (total: 1, self: —)', + ' - C (total: 1, self: 1)', + ' - C (total: 1, self: 1)', + ]); + dispatch(popTransformsFromStack(0)); + }); + }); }); describe('expanded and selected CallNodePaths', function () { diff --git a/src/types/transforms.ts b/src/types/transforms.ts index 17b9d3fdfe..01130ef869 100644 --- a/src/types/transforms.ts +++ b/src/types/transforms.ts @@ -24,10 +24,25 @@ import type { ImplementationFilter } from './actions'; /** * This type represents the filter types for the 'filter-samples' transform. - * Currently the only filter type is 'marker-search', but in the future we may - * add more types of filters. + * + * - 'marker-search': keep only samples whose timestamp falls within a matching marker range. + * - 'outside-marker': keep only samples whose timestamp falls OUTSIDE any matching marker range. + * - 'function-include': keep only samples whose stack contains any of the given functions + * (encoded as comma-separated funcIndexes in the `filter` string). + * - 'stack-prefix': keep only samples whose stack starts with the given sequence of functions + * (encoded as comma-separated funcIndexes, root-first). + * - 'stack-suffix': keep only samples whose leaf frame is the given function + * (encoded as a single funcIndex). + * + * Note: 'outside-marker', 'function-include', 'stack-prefix', and 'stack-suffix' are used + * by the pq CLI tool only and are not serialized to profile URLs. */ -export type FilterSamplesType = 'marker-search'; +export type FilterSamplesType = + | 'marker-search' + | 'outside-marker' + | 'function-include' + | 'stack-prefix' + | 'stack-suffix'; /* * Define all of the transforms on an object to conveniently access mapped types and do @@ -395,13 +410,11 @@ export type TransformDefinitions = { }; /** - * Filter the samples in the thread by the filter. - * Currently it only supports filtering by the marker name but can be extended - * to support more filters in the future. + * Filter the samples in the thread by the filter. See FilterSamplesType for + * the supported filter types. */ 'filter-samples': { readonly type: 'filter-samples'; - // Expand this type when you need to support more than just the marker. readonly filterType: FilterSamplesType; readonly filter: string; }; From e602811ca0d3b6c13d1b031582b2a1cfea779c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Sat, 11 Apr 2026 12:49:58 +0200 Subject: [PATCH 24/63] Add ephemeral and sticky filters for samples --- pq-filters-and-bookmarks-proposal.md | 751 +++++++++------------------ profile-query-cli/guide.txt | 121 ++++- profile-query-cli/src/daemon.ts | 28 +- profile-query-cli/src/formatters.ts | 117 ++++- profile-query-cli/src/index.ts | 336 +++++++++++- profile-query-cli/src/protocol.ts | 15 + src/profile-query/filter-stack.ts | 234 +++++++++ src/profile-query/index.ts | 226 +++++++- src/profile-query/thread-map.ts | 19 +- src/profile-query/types.ts | 50 ++ 10 files changed, 1338 insertions(+), 559 deletions(-) create mode 100644 src/profile-query/filter-stack.ts diff --git a/pq-filters-and-bookmarks-proposal.md b/pq-filters-and-bookmarks-proposal.md index 03a49f3fad..70b9fc22b1 100644 --- a/pq-filters-and-bookmarks-proposal.md +++ b/pq-filters-and-bookmarks-proposal.md @@ -1,8 +1,8 @@ # pq Filters and Bookmarks Proposal -**Status:** Proposal (revised based on feedback) +**Status:** Phase 1 + Phase 2 implemented; Phase 3 (strip-prefix, strip-suffix, merge-regex) and marker display filters and bookmarks are future work **Created:** 2025-01-04 -**Last Updated:** 2025-01-04 +**Last Updated:** 2026-04-11 **Related:** pq-todo.md --- @@ -14,14 +14,15 @@ Based on feedback, this proposal includes: 1. ✅ **Consistent terminology** - `push/pop/clear` for zoom and filters, `load/unload` for bookmarks 2. ✅ **Multi-thread selection** - `thread select t-0,t-93` works for sticky state 3. ✅ **Clear OR vs AND** - `-any` suffix for OR, repeated flags for AND -4. ✅ **Per-profile bookmarks** - scoped to current profile, not global +4. ✅ **Per-profile bookmarks** - scoped to current profile, not global (future) 5. ✅ **Zoom validation** - nested ranges must be contained within parent 6. ✅ **Unified naming** - `--includes-prefix` instead of `--starts-with-function`/`--starts-with-sequence` -7. ✅ **Balanced zoom syntax** - `zoom push ts-6,ts-7` and `zoom push --marker m-158` +7. ✅ **Balanced zoom syntax** - `zoom push ts-6,ts-7` and `zoom push m-158` 8. ✅ **Single filter stack** - one ordered stack for sample/stack filters (order matters for dependencies) -9. ✅ **Prefix means exact sequence** - `--includes-prefix A,B,C` means starts with A→B→C exactly +9. ✅ **Prefix means exact sequence** - `--includes-prefix f-1,f-2,f-3` means starts with f-1→f-2→f-3 exactly 10. ✅ **Per-thread filter stacks** - each thread has its own filter context -11. ✅ **Separate marker filters** - marker display filtering independent from sample/stack filtering +11. ✅ **Function handles are global** - `f-N` is index N into the shared funcTable, stable across sessions and threads +12. ⏳ **Separate marker filters** - marker display filtering independent from sample/stack filtering (future) --- @@ -31,57 +32,69 @@ This proposal defines a comprehensive system for managing analysis state in pq w 1. **Thread selection** (global) - which thread(s) you're analyzing 2. **Zoom** (global) - time range you're focused on -3. **Sample/Stack filters** (per-thread) - how to filter and transform samples -4. **Marker filters** (per-thread) - how to filter marker display +3. **Sample/Stack filters** (per-thread) - how to filter and transform samples ✅ implemented +4. **Marker filters** (per-thread) - how to filter marker display ⏳ future Each dimension supports: -- **Ephemeral use** - apply once via flags +- **Ephemeral use** - apply once via flags (applies to that command only) - **Sticky state** - persists across commands via push/pop/clear -- **Bookmarks** - save and restore complex views +- **Bookmarks** - save and restore complex views ⏳ future --- ## Design Principles 1. **Ephemeral by default** - All commands accept filter flags that apply only to that invocation -2. **Explicit stickiness** - Making state sticky requires explicit commands (`select`, `zoom push`, `filter add`) +2. **Explicit stickiness** - Making state sticky requires explicit commands (`select`, `zoom push`, `filter push`) 3. **Clear state** - `pq status` always shows current thread, zoom, and active filters 4. **Composable** - Filters, zoom, and thread selection are independent dimensions -5. **Saveable** - Complex views can be bookmarked and recalled +5. **Saveable** - Complex views can be bookmarked and recalled ⏳ future --- ## Core Syntax -### 1. Ephemeral Filters (Flags) +### 1. Ephemeral Filters (Flags) ✅ implemented -All commands accept filter flags that apply only to that command: +The following filter flags work on `thread samples`, `thread samples-top-down`, +`thread samples-bottom-up`, and `thread functions`. They apply only to that one +invocation and do not affect the sticky filter stack. ```bash -# Ephemeral thread selection -pq thread samples --thread t-93 -pq thread markers --threads t-0,t-93 - -# Ephemeral zoom -pq thread samples --zoom ts-6,ts-7 -pq thread markers --zoom m-158 - -# Ephemeral sample filters -pq thread samples --includes-any-function PresentImpl,FlushD3D11 +# Ephemeral sample inclusion/exclusion +pq thread samples --includes-function f-142 +pq thread samples --includes-any-function f-142,f-143 +pq thread samples --includes-prefix f-100,f-101,f-142 +pq thread samples --includes-suffix f-142 +pq thread samples --excludes-function f-142 +pq thread samples --excludes-any-function f-142,f-143 + +# Ephemeral marker-based filters pq thread samples --during-marker --search Paint -pq thread samples --includes-prefix f-1,f-2,f-3 +pq thread samples --outside-marker --search GC # Ephemeral stack transforms -pq thread samples --merge malloc,free,arena_dalloc +pq thread samples --merge f-142,f-143 pq thread samples --root-at f-142 -pq thread samples --strip-prefix f-1,f-2,f-3 -# Combinations work -pq thread samples --thread t-93 --zoom ts-6,ts-7 --merge malloc --limit 20 +# Multiple ephemeral filters on one command — applied in left-to-right order +pq thread samples --excludes-function f-142 --merge f-143 --during-marker --search Paint + +# Ephemeral and sticky filters compose: +# sticky filters are applied first (already in the Redux transform stack), +# then ephemeral filters are layered on top for that one invocation. +pq thread functions --limit 20 --excludes-function f-142 ``` -### 2. Sticky Thread Selection +**Not yet implemented for ephemeral use:** + +- `--zoom ` on thread commands (use `pq zoom push` instead) +- `--threads t-0,t-93` on thread commands (use `pq thread select` instead) +- `--strip-prefix`, `--strip-suffix`, `--merge-regex` (Phase 3, see below) +- `--during-marker --category` (only `--search` is supported) + +### 2. Sticky Thread Selection ✅ implemented ```bash pq thread select t-93 # Select single thread (sticky) @@ -93,7 +106,7 @@ pq thread samples # Uses both threads pq thread select t-0 # Switch to different thread ``` -### 3. Sticky Zoom (Stack-based) +### 3. Sticky Zoom (Stack-based) ✅ implemented ```bash pq zoom push ts-6,ts-7 # Push zoom level @@ -107,51 +120,41 @@ pq zoom pop # Pop again (back to full profile) pq zoom clear # Clear entire zoom stack -# Convenience forms -pq zoom push m-158 # Zoom to marker's time range (✅ implemented) -pq zoom push --spike 1 # Zoom to first CPU spike (future feature) +pq zoom push m-158 # Zoom to marker's time range ``` -### 4. Sticky Sample/Stack Filters (Per-Thread) +### 4. Sticky Sample/Stack Filters (Per-Thread) ✅ implemented -Each thread has its own filter stack. Filters are **scoped to the current thread** because they apply to that thread's sample data and transforms. +Each thread has its own filter stack. `pq filter push` appends a filter that +persists across all subsequent analysis commands until popped. -Function handles in filter expressions refer to canonical shared `funcTable` -indices, e.g. `f-142` means shared function index 142 for the loaded profile. -The filter state remains per-thread because the sample data and transform stacks -are per-thread. +Function handles (`f-N`) identify index N in the shared funcTable — they are +stable across sessions for the same profile. ```bash # Select thread first pq thread select t-93 -# Push filters onto THIS THREAD's stack -# Each filter is applied in push order - -# Sample filters (which samples to include) -pq filter push --includes-any-function f-142,f-143 # Filter 1: includes f-142 OR f-143 -pq filter push --includes-function f-142 # Filter 2: AND (must also have f-142) -pq filter push --includes-function f-200 # Filter 3: AND (must also have f-200) - -pq filter push --during-marker --search Paint # Filter 4: during Paint markers -pq filter push --includes-prefix f-100,f-200 # Filter 5: stack starts f-100→f-200 -pq filter push --includes-suffix f-142 # Filter 6: stack ends with f-142 +# Push filters onto THIS THREAD's stack (applied in push order) -# Inverse sample filters (exclude) -pq filter push --excludes-any-function malloc,free # Exclude samples -pq filter push --outside-marker --search GC +# Include/exclude samples +pq filter push --includes-function f-142 # keep samples whose stack contains f-142 +pq filter push --includes-any-function f-142,f-143 # keep samples with f-142 OR f-143 +pq filter push --includes-prefix f-100,f-200 # keep samples whose stack starts f-100→f-200 +pq filter push --includes-suffix f-142 # keep samples whose leaf frame is f-142 +pq filter push --excludes-function f-142 # drop samples containing f-142 +pq filter push --excludes-any-function f-142,f-143 # drop samples containing f-142 or f-143 +pq filter push --during-marker --search Paint # keep samples during Paint markers +pq filter push --outside-marker --search GC # keep samples outside GC markers -# Stack transform filters (modify stacks before sample filtering) -pq filter push --strip-prefix f-1,f-2,f-3 # Strip then sample-filter -pq filter push --merge malloc,free,arena_dalloc # Merge away allocators -pq filter push --merge-regex "^(malloc|free|moz_x)" -pq filter push --root-at f-142 # Re-root stacks -pq filter push --strip-suffix f-999 +# Stack transforms +pq filter push --merge f-142,f-143,f-144 # collapse these functions out of stacks +pq filter push --root-at f-142 # re-root all stacks at f-142 -# Order matters! Example: -pq filter push --strip-prefix A,B,C # 1. Strip A→B→C from stacks -pq filter push --includes-prefix D # 2. Then filter by prefix D -# Filter 2 sees stacks AFTER filter 1 has transformed them +# Order matters — each filter sees stacks as left by the previous filter: +pq filter push --root-at f-100 # 1. re-root at f-100 +pq filter push --includes-prefix f-100 # 2. keep only stacks starting with f-100 +# Filter 2 operates on stacks already transformed by filter 1 # Management pq filter list # Show filters for current thread @@ -159,239 +162,120 @@ pq filter pop # Pop most recent filter pq filter pop 3 # Pop 3 most recent filters pq filter clear # Clear all filters for current thread -# Switch threads - different filter stack! +# Switch threads — different filter stack! pq thread select t-0 pq filter list # t-0's filters (independent from t-93) -# Filters apply to sample analysis commands -pq thread samples # Uses current thread's filters +# All analysis commands use current thread's filters automatically +pq thread samples +pq thread functions +pq thread samples-top-down ``` -### 5. Sticky Marker Filters (Per-Thread) +**Not yet implemented (Phase 3 / deferred):** -Each thread also has its own marker filter stack, independent from sample/stack filters. +- `--strip-prefix f-1,f-2,f-3` — structural stack mutation +- `--strip-suffix f-x` — structural stack mutation +- `--merge-regex "^pattern"` — regex-based merge across funcTable -```bash -# Select thread -pq thread select t-0 +### 5. Sticky Marker Filters (Per-Thread) ⏳ not yet implemented -# Push marker display filters onto THIS THREAD's marker filter stack +Planned: each thread will also have its own marker filter stack controlling +which markers appear in `pq thread markers` output. + +```bash +# Future syntax (not yet implemented) pq marker filter push --search Paint pq marker filter push --category Graphics pq marker filter push --min-duration 5 -pq marker filter push --max-duration 100 -pq marker filter push --has-stack - -# Marker filters affect marker display -pq thread markers # Shows only filtered markers - -# Management -pq marker filter list # Show marker filters for current thread -pq marker filter pop # Pop most recent marker filter -pq marker filter clear # Clear all marker filters for current thread - -# Switch threads - different marker filter stack! -pq thread select t-93 -pq marker filter list # t-93's marker filters (independent from t-0) - -# Marker filters only affect marker display, not sample analysis -pq thread samples # NOT affected by marker filters -pq thread markers # Affected by marker filters +pq marker filter pop +pq marker filter clear ``` -**Note:** Sample/stack filters that reference markers (e.g., `--during-marker --search Paint`) operate on the **unfiltered** marker set. Marker filters only affect display, not sample filtering logic. +Note: sample filters that reference markers (`--during-marker`, `--outside-marker`) +are already implemented and operate on the **unfiltered** marker set, independent +of any future marker display filters. -### 6. Bookmarks +### 6. Bookmarks ⏳ not yet implemented -Bookmarks save complex views for later recall. They are **per-profile** (scoped to the current session's profile). +Planned: save and restore complex views (zoom + thread selection, or filter stacks). ```bash -# Create bookmarks +# Future syntax (not yet implemented) pq bookmark view spike1 --zoom ts-6,ts-7 --threads t-0,t-93 -pq bookmark filter no-allocators --merge malloc,free,arena_dalloc - -# Use bookmarks ephemerally -pq thread samples --view spike1 -pq thread samples --filter no-allocators -pq thread samples --view spike1 --filter no-allocators - -# Load bookmarks (make sticky) -pq bookmark load view spike1 # Sets zoom + threads sticky -pq bookmark load filter no-allocators # Applies filters sticky - -# Management -pq bookmark list # Show all bookmarks for current profile -pq bookmark info spike1 # Show bookmark details -pq bookmark delete spike1 # Remove bookmark -pq bookmark export spike1 > spike1.json # Export for sharing (future) -pq bookmark import spike1.json # Import bookmark (future) +pq bookmark filter no-allocators --merge f-142,f-143 +pq thread samples --view spike1 # ephemeral bookmark use +pq bookmark load view spike1 # sticky bookmark use +pq bookmark list +pq bookmark delete spike1 ``` -### 7. Status Command +### 7. Status Command ✅ implemented -Always shows current state across all dimensions: +Shows current thread, zoom stack, and all active filter stacks: ```bash pq status -# Output: -Session: 3ugy6phmzqc -Profile: https://share.firefox.dev/4oLEjCw -Thread: t-93 (Renderer) -Zoom: ts-6,ts-7 (2.701s - 3.091s) - └─ parent: full profile (0s - 30.5s) - -Sample/Stack Filters for t-93 (applied in order): - 1. [stack transform] merge: malloc, free, arena_dalloc, je_malloc - 2. [sample filter] includes function: f-142 (dxgi.dll!CDXGISwapChain::PresentImpl) - 3. [sample filter] during markers matching: --search Paint - -Marker Filters for t-93: - 1. search: Paint - 2. min-duration: 5ms - -Filters for other threads: - t-0: 2 sample/stack filters, 1 marker filter - t-99: 1 sample/stack filter, 0 marker filters - -Bookmarks loaded: - - view: spike1 - - filter: no-allocators +# Example output: +Session Status: + Selected thread: t-0 (GeckoMain) + View range: ts-6 to ts-7 + Filters for t-0: + 1. [merge] merge: f-142, f-143 + 2. [during-marker] during marker matching: "Paint" + 3. [includes-function] includes function: f-200 ``` --- ## Filter Types -### Sample Filters (Inclusion/Exclusion) +### Sample Filters (Inclusion/Exclusion) ✅ implemented -Control which samples are included in analysis: +Control which samples are included in analysis. All take `f-N` function handles. ```bash -# Include samples where... -# OR semantics: use -any suffix with comma-separated list ---includes-any-function f-142,f-143 # Stack contains f-142 OR f-143 ---includes-function f-142 # Stack contains this function (can repeat for AND) - ---includes-prefix f-1,f-2,f-3 # Stack starts with this sequence ---includes-suffix f-999 # Stack ends with this function - ---during-marker --search Paint # Timestamp falls within matching marker ---during-marker --category Graphics # Timestamp falls within marker in category - -# Exclude samples where... ---excludes-any-function malloc,free # Stack does not contain malloc OR free ---excludes-function malloc # Stack does not contain this function ---outside-marker --search GC # Timestamp outside matching markers - -# Sample filters affect: -# - thread samples (which samples aggregate) -# - thread markers (which samples contribute to marker statistics) -# - thread functions (which samples count toward function time) - -# Combining filters: -# - Same flag repeated = AND (--includes-function f-1 --includes-function f-2 = has both) -# - -any suffix = OR (--includes-any-function f-1,f-2 = has either) -``` - -### Stack Transform Filters +# Include samples where stack contains the function(s) +--includes-function f-142 # contains f-142 (repeat push for AND) +--includes-any-function f-142,f-143 # contains f-142 OR f-143 (single push) -Modify stack traces in all samples: +--includes-prefix f-1,f-2,f-3 # stack starts with f-1→f-2→f-3 (root-first) +--includes-suffix f-999 # leaf frame is f-999 -```bash -# Merge (remove functions from stacks, collapse callers to callees) ---merge malloc,free # Remove these functions ---merge-regex "^(malloc|free)" # Remove matching functions -# A -> malloc -> B becomes A -> B - -# Root-at (show only subtree rooted at function) ---root-at f-142 # Show only time within this function -# Only shows stacks that include f-142, with f-142 as root - -# Strip prefix (remove leading frames) ---strip-prefix f-1,f-2,f-3 # Remove these from stack tops -# f-1 -> f-2 -> f-3 -> f-4 becomes f-4 - -# Strip suffix (remove trailing frames) ---strip-suffix f-999 # Remove these from stack bottoms -# f-1 -> f-2 -> f-999 becomes f-1 -> f-2 - -# Stack transforms affect: -# - thread samples (how stacks are displayed) -# - thread functions (which functions appear in list) -# - Call tree structure -``` +--during-marker --search Paint # timestamp falls within a matching marker -### Thread and Zoom (Spatial/Temporal) +# Exclude samples where stack contains the function(s) +--excludes-function f-142 +--excludes-any-function f-142,f-143 # contains f-142 OR f-143 -Not technically "filters" but part of the view context: +--outside-marker --search GC # timestamp outside matching markers -```bash -# Thread selection ---thread t-93 # Single thread (ephemeral) ---threads t-0,t-93 # Multiple threads (ephemeral) -pq thread select t-93 # Single thread (sticky) - -# Zoom (time range) ---zoom ts-6,ts-7 # Specific range (ephemeral) ---zoom m-158 # Marker's time range (ephemeral) -pq zoom push ts-6,ts-7 # Push range (sticky) -pq zoom push m-158 # Push marker's range (sticky, ✅ implemented) +# AND semantics via repeated push: +pq filter push --includes-function f-1 # keep samples with f-1 +pq filter push --includes-function f-2 # AND also with f-2 ``` ---- - -## Bookmark Types +**Not yet implemented:** -### View Bookmarks +- `--during-marker --category ` (only `--search` supported) -Capture spatial/temporal context (where/when you're looking): +### Stack Transform Filters ✅ partially implemented -```bash -pq bookmark view spike1 --zoom ts-6,ts-7 --threads t-0,t-93 -pq bookmark view gpu-idle --thread t-93 --zoom ts-p,ts-q - -# View bookmarks include: -# - Time range (zoom level) -# - Thread selection (single or multiple) -``` - -### Filter Bookmarks - -Capture analytical transforms (how you're analyzing): +Modify the structure of stacks before or after sample filtering. ```bash -pq bookmark filter no-allocators \ - --merge malloc,free,arena_dalloc,je_malloc \ - --merge-regex "^moz_x" - -pq bookmark filter paint-only \ - --during-marker --search Paint \ - --root-at f-50 - -# Filter bookmarks include: -# - Sample filters (includes/excludes) -# - Stack transforms (merge/root/strip) +# Merge: remove functions from stacks, collapsing their callers to callees +--merge f-142,f-143 # f-A → f-142 → f-B becomes f-A → f-B +--root-at f-142 # re-root all stacks at f-142 (focus-function) ``` -### Using Bookmarks +**Phase 3 (not yet implemented):** ```bash -# Ephemeral use (doesn't change state) -pq thread samples --view spike1 -pq thread samples --filter no-allocators -pq thread samples --view spike1 --filter no-allocators - -# Sticky load (changes state) -pq bookmark load view spike1 # Sets thread + zoom -pq bookmark load filter no-allocators # Applies filters -pq status # Shows loaded bookmarks -pq thread samples # Uses loaded context - -# Unload -pq bookmark unload view # Clear view bookmark -pq bookmark unload filter # Clear filter bookmark -pq bookmark unload all # Clear all bookmarks +--strip-prefix f-1,f-2,f-3 # remove leading root frames from stacks +--strip-suffix f-999 # remove trailing leaf frames from stacks +--merge-regex "^(malloc|free)" # merge functions matching a regex ``` --- @@ -403,25 +287,19 @@ pq bookmark unload all # Clear all bookmarks ```bash # See profile overview $ pq profile info -# Output shows: "160% CPU for 390ms [ts-6,ts-7]" +# Output shows a spike around ts-6,ts-7 # Quick peek at that spike -$ pq thread samples --zoom ts-6,ts-7 --limit 20 -# Looks interesting, main thread doing Paint +$ pq zoom push ts-6,ts-7 +$ pq thread samples --limit 20 -# Check GPU thread during same time -$ pq thread samples --thread t-93 --zoom ts-6,ts-7 --limit 20 -# GPU doing Present +# Check what's happening in the Renderer thread during that time +$ pq thread samples --thread t-26 --limit 20 -# Make spike sticky to investigate further -$ pq zoom push ts-6,ts-7 -$ pq thread select t-0 +# Make thread selection sticky and investigate further +$ pq thread select t-26 $ pq thread samples -# Check markers from both threads -$ pq thread markers --threads t-0,t-93 -# See WM_PAINT on t-0, Composite on t-93 at same time - # Done with spike $ pq zoom pop ``` @@ -429,312 +307,165 @@ $ pq zoom pop ### Scenario 2: Eliminating Allocator Noise ```bash -# Call tree is noisy -$ pq thread samples -# Lots of malloc/free/arena_dalloc +# Call tree shows allocator noise +$ pq thread functions --search malloc +# → f-142. libmalloc!malloc_zone_malloc - self: 8.2% -# Try merging ephemerally -$ pq thread samples --merge malloc,free,arena_dalloc --limit 30 +# Try merging ephemerally first +$ pq thread samples --merge f-142,f-143 --limit 30 # Better! See actual work # Make it sticky -$ pq filter push --merge malloc,free,arena_dalloc,je_malloc -$ pq filter push --merge-regex "^moz_x" +$ pq filter push --merge f-142,f-143 $ pq thread samples -# Clean call tree across all commands - -# Save for future use -$ pq bookmark filter no-allocators --merge malloc,free,arena_dalloc,je_malloc --merge-regex "^moz_x" +# Clean call tree across all subsequent commands -# Later session (on same profile) -$ pq bookmark load filter no-allocators +# Remove when done +$ pq filter clear ``` -### Scenario 3: Analyzing Time in Specific Function +### Scenario 3: Analyzing Time in a Specific Function ```bash -# Find expensive function +# Find an expensive function $ pq thread functions --search PresentImpl -# f-142. dxgi.dll!CDXGISwapChain::PresentImpl - 16.4% total +# → f-500. XUL!CDXGISwapChain::PresentImpl - self: 16.4% -# See what it's calling (ephemeral root) -$ pq thread samples --root-at f-142 --limit 30 -# Shows subtree rooted at PresentImpl +# Ephemeral: see the subtree rooted at it +$ pq thread samples --root-at f-500 --limit 30 -# Also filter to only samples that include it -$ pq filter push --includes-function f-142 -$ pq filter push --root-at f-142 +# Sticky: focus all analysis on samples containing it +$ pq filter push --includes-function f-500 +$ pq filter push --root-at f-500 $ pq thread samples -# Now analyzing only time within PresentImpl - -# Check markers during this work -$ pq thread markers -# Sample filters affect marker aggregation too +$ pq thread functions # Clear when done $ pq filter clear ``` -### Scenario 4: Cross-Thread Causality Chain +### Scenario 4: Cross-Thread Causality ```bash -# Main thread fires Paint +# Look at main thread markers $ pq thread select t-0 $ pq marker info m-158 -# WindowProc WM_PAINT at 1h2m (33.52ms) +# WindowProc WM_PAINT at ... (33.52ms) # Zoom to that marker $ pq zoom push m-158 -# See what GPU did during same time -$ pq thread samples --thread t-93 --limit 20 -# GPU doing Present work +# See what the Renderer thread was doing during the same window +$ pq thread samples --thread t-26 --limit 20 -# Make GPU thread sticky -$ pq thread select t-93 - -# Only look at samples during Paint markers +# Make it sticky and drill further +$ pq thread select t-26 $ pq filter push --during-marker --search Paint $ pq thread samples -# Now only see GPU work that happened during Paint - -# Save this analysis (bookmarks are per-profile) -$ pq bookmark view paint-causality --zoom m-158 --thread t-93 -$ pq bookmark filter paint-only --during-marker --search Paint +# Now only see Renderer work that happened during Paint markers +$ pq filter clear -# Later: reload entire analysis (same profile) -$ pq bookmark load view paint-causality -$ pq bookmark load filter paint-only -$ pq thread samples +$ pq zoom pop ``` --- -## Implementation Notes +## Implementation Status -### Per-Thread Filter Scoping +### Phase 1 — Implemented ✅ (Redux transform-backed) -**Why per-thread?** Filter state is thread-scoped because it operates on a thread's sample data and transform stack. +| Flag | Redux transform | +| ------------------------------------------------------------- | ---------------------------------- | +| `--excludes-function f-x` / `--excludes-any-function f-x,f-y` | `drop-function` (one per func) | +| `--merge f-x,f-y,f-z` | `merge-function` (one per func) | +| `--root-at f-x` | `focus-function` | +| `--during-marker --search Text` | `filter-samples` (`marker-search`) | -Function handles themselves are not thread-local in the current implementation; -they identify shared functions for the loaded profile. +### Phase 2 — Implemented ✅ (extended `filter-samples` transforms) -```bash -pq thread select t-0 -pq thread functions --search Present -# f-142. dxgi.dll!CDXGISwapChain::PresentImpl on t-0 +New `FilterSamplesType` values in `src/types/transforms.ts` / `src/profile-logic/transforms.ts`: -pq filter push --includes-function f-142 # Filter for t-0 - -pq thread select t-93 -# t-93 has its own independent filter stack (empty initially) -``` +| Flag | filterType | Logic | +| ------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------- | +| `--includes-function f-x` / `--includes-any-function f-x,f-y` | `function-include` | Walk stackTable; keep samples whose stack contains any of the funcs | +| `--includes-prefix f-1,f-2,f-3` | `stack-prefix` | Keep samples whose root-first frame sequence matches | +| `--includes-suffix f-x` | `stack-suffix` | Keep samples whose leaf frame is the given func | +| `--outside-marker --search Text` | `outside-marker` | Inverse of `marker-search` | -**Behavior when switching threads:** +### Phase 3 — Deferred -- Each thread maintains its own sample/stack filter stack -- Each thread maintains its own marker filter stack -- Zoom is global (applies across all threads) -- Thread selection is global (which thread is currently active) +- `--strip-prefix f-1,f-2,f-3` — requires new structural stack transform (removes leading frames) +- `--strip-suffix f-x` — requires new structural stack transform (removes trailing frames) +- `--merge-regex "^pattern"` — requires regex scan of the funcTable before dispatching transforms -**Status summary:** +### Architecture -- `pq status` shows filters for current thread in detail -- Lists other threads with filter count summaries +- **`src/profile-query/filter-stack.ts`** — `FilterStack` class: per-thread `Map`; `push()` dispatches Redux transforms immediately; `pop()` uses `POP_TRANSFORMS_FROM_STACK` to unwind them; `pushSpecTransforms()` exported for ephemeral use +- **`ProfileQuerier`** — `filterPush/Pop/List/Clear` for sticky filters; `_withEphemeralFilters()` for one-shot use; filter state in `getStatus()` +- Sticky filters live in the Redux transform stack and are automatically applied by all analysis commands via `getFilteredThread()` +- Ephemeral filters are pushed on top of sticky ones for the duration of one call, then popped +- `outside-marker`, `function-include`, `stack-prefix`, `stack-suffix` are pq-only and not URL-serializable -### Filter Application Order - -Sample/stack filters are applied in **push order** (the order you pushed them onto the stack). - -This is critical because filters can depend on each other: - -```bash -# Example: Strip prefix, then filter by new prefix -pq filter push --strip-prefix A,B,C # 1. Transform: remove A→B→C -pq filter push --includes-prefix D # 2. Filter: include only stacks starting with D -# Filter 2 sees stacks AFTER filter 1's transformation - -# Example: Filter samples, then merge stacks -pq filter push --includes-function PresentImpl # 1. Only samples with PresentImpl -pq filter push --merge malloc,free # 2. Merge allocators in those samples -``` - -**General guidance for typical use:** - -1. Stack transforms first (--strip-prefix, --merge, --root-at) -2. Sample filters second (--includes, --excludes, --during-marker) - -But the single ordered stack gives you flexibility when needed. - -### Marker Filters vs Sample Filters Using Markers - -**Two different concepts:** - -1. **Marker filters** - affect marker display only - - ```bash - pq marker filter push --search Paint - pq thread markers # Shows only Paint markers - ``` - -2. **Sample filters using markers** - filter samples based on marker timing - ```bash - pq filter push --during-marker --search Paint - pq thread samples # Shows only samples during Paint markers - ``` - -**Important:** Sample filters that reference markers (e.g., `--during-marker`) use the **unfiltered** marker set, not the display-filtered set. Marker filters only affect `pq thread markers` output. - -### Zoom Stack Behavior +--- -Zoom works like a stack, and nested zooms must be contained within parent ranges: +## Implementation Notes -```bash -pq zoom push ts-6,ts-7 # Stack: [full profile, ts-6 to ts-7] -pq zoom push ts-6a,ts-6c # Stack: [full profile, ts-6 to ts-7, ts-6a to ts-6c] - # Validates ts-6a,ts-6c is within ts-6,ts-7 -pq zoom pop # Stack: [full profile, ts-6 to ts-7] -pq zoom pop # Stack: [full profile] -pq zoom pop # No-op, already at root - -# Invalid: non-nested range -pq zoom push ts-6,ts-7 -pq zoom push ts-A,ts-B # Error: ts-A,ts-B not within ts-6,ts-7 -``` +### Per-Thread Filter Scoping -### Filter Stack Behavior +**Why per-thread?** The Redux transform stack is per-thread (`ThreadsKey` → `TransformStack`). Pushing a filter dispatches to a specific thread's transform stack. -Filters form a **single ordered stack** regardless of filter type: +Function handles themselves are global (index into `profile.shared.funcTable`), so `f-142` means the same function regardless of which thread's filter stack it is pushed onto. ```bash -pq filter push --includes-function f-142 # Stack: [f-142] -pq filter push --during-marker --search Paint # Stack: [f-142, Paint] -pq filter push --merge malloc # Stack: [f-142, Paint, malloc] - -pq filter list -# Shows: 3 filters in order -# 1. [sample filter] includes function: f-142 -# 2. [sample filter] during markers: --search Paint -# 3. [stack transform] merge: malloc - -pq filter pop # Remove most recent filter (malloc) - # Stack: [f-142, Paint] - -pq filter pop 2 # Remove 2 most recent filters - # Stack: [] - -pq filter clear # Remove ALL filters - # Stack: [] - -# Save complex filter stacks via bookmarks -pq bookmark filter my-analysis --includes f-142 --during-marker --search Paint --merge malloc -``` - -### Bookmark Storage - -Bookmarks are **per-profile** and session-local (stored in daemon memory): - -- Scoped to the current profile URL/path -- Lost when session ends (`pq stop`) -- Not shared across different profiles or sessions -- Loading a different profile = different set of bookmarks -- Future: could persist to ~/.pq/bookmarks// for cross-session use -- Future: export/import for sharing with colleagues - -### Status Command - -`pq status` output structure: +pq thread select t-0 +pq filter push --includes-function f-142 # on t-0's transform stack +pq thread select t-26 +pq filter list # t-26's stack (empty — independent from t-0) ``` -Session: -Thread: () -Zoom: - [└─ parent: ] # If zoomed - [└─ parent: ] # Can be nested -[Filters (applied in order):] - [N. [type] ] - [...] -[Bookmarks loaded:] - [- view: ] - [- filter: ] -``` - -Filter types displayed: -- `[sample filter]` - includes/excludes samples -- `[stack transform]` - modifies stacks -- `[marker filter]` - temporal filtering - ---- - -## Future Enhancements - -### Auto-generated Bookmarks - -System could create bookmarks automatically: - -```bash -pq profile hotspots -# Output: -# 1. [ts-6,ts-7] 160% CPU for 390ms -# bookmark: spike:1 -# 2. [ts-A,ts-b] 160% CPU for 450ms -# bookmark: spike:2 - -pq bookmark load view spike:1 -# Automatically created bookmarks -``` +### Filter Application Order -### Bookmark Export/Import +Filters are applied in **push order**. Because each filter sees the thread data +as transformed by all previous filters, the order determines the semantics: ```bash -pq bookmark export spike1 > spike1.json -pq bookmark import spike1.json +# Root-at first, then include-prefix: the prefix check runs on re-rooted stacks +pq filter push --root-at f-100 +pq filter push --includes-prefix f-100 -# Share bookmarks with colleagues +# Merge first, then include: only samples where f-142 appears AFTER merging allocators +pq filter push --merge f-300,f-301 +pq filter push --includes-function f-142 ``` -### Bookmark Aliases +### Actual `pq status` Output Format -```bash -pq bookmark alias s1 spike1 -pq thread samples --view s1 ``` - -### Smart Merge Sets - -```bash -pq bookmark filter no-alloc --merge @allocators -# @allocators = predefined set: malloc, free, arena_dalloc, etc. - -pq bookmark filter no-overhead --merge @profiler-overhead -# @profiler-overhead = profiler stack frames +Session Status: + Selected thread: t-0 (GeckoMain) + View range: Full profile + Filters for t-0: + 1. [excludes-function] excludes function: f-184 + 2. [during-marker] during marker matching: "Paint" + 3. [includes-function] includes function: f-200 ``` +Filter type labels in output match the `SampleFilterSpec.type` field: +`excludes-function`, `merge`, `root-at`, `during-marker`, `includes-function`, +`includes-prefix`, `includes-suffix`, `outside-marker`. + --- ## Open Questions -1. ✅ **Multiple threads in sticky state?** - RESOLVED - - `thread select t-0,t-93` supports multi-thread selection - -2. ✅ **Filter combination logic?** - RESOLVED - - Use `-any` suffix for OR: `--includes-any-function f-1,f-2` - - Repeated flags for AND: `--includes-function f-1 --includes-function f-2` - -3. ✅ **Zoom validation?** - RESOLVED - - Nested zooms must be contained within parent ranges - - Error if attempting to zoom to non-nested range - -4. ✅ **Bookmark namespaces?** - RESOLVED - - Bookmarks are per-profile (scoped to current profile) - -5. ✅ **Prefix semantics?** - RESOLVED - - `--includes-prefix f-1,f-2,f-3` means exactly f-1→f-2→f-3 sequence - -6. ✅ **Filter application order?** - RESOLVED - - Single ordered stack, applied in push order - - Order matters (can strip prefix then filter by new prefix) +1. ✅ **Multiple threads in sticky state?** - RESOLVED: `thread select t-0,t-93` +2. ✅ **Filter combination logic?** - RESOLVED: `-any` suffix for OR, repeated pushes for AND +3. ✅ **Zoom validation?** - RESOLVED: nested zooms validated against parent range +4. ✅ **Bookmark namespaces?** - RESOLVED design (per-profile); implementation deferred +5. ✅ **Prefix semantics?** - RESOLVED: `--includes-prefix f-1,f-2,f-3` means exactly f-1→f-2→f-3 root-first +6. ✅ **Filter application order?** - RESOLVED: single ordered stack applied in push order +7. ✅ **Function handle scope?** - RESOLVED: `f-N` is global (shared funcTable index), not per-thread --- @@ -742,16 +473,16 @@ pq bookmark filter no-overhead --merge @profiler-overhead This proposal creates a consistent, composable system for managing analysis state in pq: -- **Ephemeral filters** via flags work everywhere -- **Sticky state** via explicit push/pop/clear commands +- **Ephemeral filters** via flags on any thread analysis command — not persisted ✅ +- **Sticky filters** via `filter push/pop/clear` — persists across commands ✅ - **Consistent terminology**: push/pop/clear for zoom and filters, load/unload for bookmarks -- **Stack-based**: zoom and filters can be layered and unwound -- **Single filter stack**: one ordered stack for all filters (order matters!) -- **Filter dependencies**: can transform stacks then filter samples -- **OR vs AND semantics**: `-any` suffix for OR, repeated flags for AND -- **Per-profile bookmarks**: scoped to current profile -- **Nested zoom validation**: ranges must be properly contained -- **Status** always shows current context including filter order -- **Composable** - filters, zoom, threads are independent dimensions - -The system is designed to be discoverable (flags are ephemeral by default) while supporting power-user workflows (sticky state and bookmarks). +- **Stack-based**: zoom and filters can be layered and unwound ✅ +- **Single filter stack**: one ordered stack for all filters per thread (order matters!) ✅ +- **OR vs AND semantics**: `-any` suffix for OR within a single push, repeated pushes for AND ✅ +- **Global function handles**: `f-N` stable across sessions and threads ✅ +- **Status** always shows current context including active filters ✅ +- **Composable**: ephemeral filters layer on top of sticky ones ✅ +- **Nested zoom validation**: ranges must be properly contained ✅ +- **Bookmarks**: design resolved, implementation deferred ⏳ +- **Marker display filters**: design resolved, implementation deferred ⏳ +- **Phase 3 stack transforms** (`--strip-prefix/suffix`, `--merge-regex`): deferred ⏳ diff --git a/profile-query-cli/guide.txt b/profile-query-cli/guide.txt index 37a548427b..50719fd103 100644 --- a/profile-query-cli/guide.txt +++ b/profile-query-cli/guide.txt @@ -11,7 +11,7 @@ SESSION MODEL 2. Query the loaded profile with any other commands 3. Stop with "pq stop" when done - The daemon persists your selected thread and zoom ranges across queries. + The daemon persists your selected thread, zoom ranges, and filter stacks across queries. CORE WORKFLOW @@ -51,9 +51,23 @@ CORE WORKFLOW Step 6: Drill into specifics pq marker info m-1234 Full details for a marker (from handles in marker list) pq marker stack m-1234 Full stack trace at the time of a marker - pq function info f-12 Function details (callers/callees, source location) + pq function info f-12 Function details (source location, library) pq function expand f-12 Show full untruncated function name + Step 7: Filter to focus the analysis (see FILTERS section below) + + Ephemeral -- applies only to that one command, sticky state unchanged: + pq thread samples --excludes-function f-184 Ignore one noisy function + pq thread samples --root-at f-500 Show only work inside f-500 + pq thread samples --during-marker --search Paint Only samples during Paint markers + + Sticky -- persists across all subsequent commands until popped: + pq filter push --merge f-142,f-143 Merge allocators out of stacks + pq filter push --includes-function f-500 Keep only samples containing f-500 + pq thread samples Uses sticky filters automatically + pq filter pop Remove last filter + pq filter clear Remove all filters + HANDLE SYSTEM @@ -63,7 +77,9 @@ HANDLE SYSTEM f-12 Function handles (from "thread samples", "thread functions") ts-6 Timestamp handles (named points in time, usable with "zoom push") - Handles are stable within a session but may differ across sessions. + Thread and marker handles are stable within a session. + Function handles (f-N) are stable across sessions for the same profile -- + they are direct indices into the shared function table. ZOOM: FOCUS ON A TIME RANGE @@ -76,12 +92,75 @@ ZOOM: FOCUS ON A TIME RANGE pq zoom push ts-6,ts-12 Zoom to the range between two named timestamps pq zoom pop Undo the last zoom pq zoom clear Return to full profile view - pq status Show current thread selection and active zoom ranges After zooming, all thread/marker/sample commands automatically apply the filter. Use "pq status" to confirm the active zoom stack (along with thread and filters). +FILTERS + + Filters narrow which samples appear in analysis results. They work in two modes: + + EPHEMERAL (one command only) + Add filter flags directly to thread samples/functions commands. They apply + only to that invocation; the sticky filter stack is unchanged. Multiple flags + may be combined on one command and are applied left to right. + + pq thread samples --excludes-function f-184 + pq thread samples --merge f-142,f-143 --root-at f-500 --limit 30 + pq thread functions --includes-function f-500 + + STICKY (persists across commands) + Use "pq filter push" to add a filter to the current thread's stack. + Every subsequent analysis command sees all pushed filters automatically. + Filters are per-thread: each thread has its own independent filter stack. + Exactly one filter flag per "filter push" command. + + pq filter push --merge f-142,f-143 # add filter 1 + pq filter push --includes-function f-500 # add filter 2 (AND: both must pass) + pq thread samples # both filters active + pq thread functions # both filters active + pq filter pop # remove filter 2 + pq filter pop 2 # remove last 2 filters + pq filter list # show active filters in push order + pq filter clear # remove all filters + + Use "pq status" to inspect the full session state: selected thread, zoom stack, and + all per-thread filters. + + Sticky + ephemeral compose: sticky filters apply first (already in the transform + stack), then ephemeral filters layer on top for that one invocation only. + + AVAILABLE FILTER FLAGS + + Inclusion -- keep only samples whose stack matches: + --includes-function f-N,... stack contains any of these funcs (OR) + --includes-any-function f-N,... alias for --includes-function + --includes-prefix f-N,... stack starts with this root-first sequence + --includes-suffix f-N leaf (innermost) frame is f-N + --during-marker --search sample timestamp inside a matching marker + --outside-marker --search sample timestamp outside all matching markers + + Exclusion -- drop matching samples: + --excludes-function f-N,... stack contains any of these funcs + --excludes-any-function f-N,... alias for --excludes-function + + Stack transforms -- modify stack structure: + --merge f-N,... remove funcs from stacks (A→f-N→B becomes A→B) + --root-at f-N re-root all stacks at f-N (subtree within f-N) + + COMBINING FILTERS + + OR within one push: --includes-function f-1,f-2 keeps samples with f-1 OR f-2. + AND across pushes: two separate "filter push" calls both must pass. + Push order matters: each filter sees the stack as left by the prior filter. + + Example -- only samples containing f-500, during Paint markers, after merging noise: + pq filter push --merge f-142,f-143 + pq filter push --includes-function f-500 + pq filter push --during-marker --search Paint + + JSON OUTPUT Add --json to any command to get structured JSON output, useful for piping to jq: @@ -106,6 +185,27 @@ COMMON ANALYSIS PATTERNS pq thread samples What was executing during that period? pq zoom pop Back to full profile + Eliminate allocator noise from a call tree: + pq thread functions --search malloc Find allocator function handles (e.g. f-142) + pq thread samples --merge f-142 Try ephemerally first + pq filter push --merge f-142,f-143 Make it sticky for all subsequent commands + pq thread samples Clean call tree, filters persist + pq filter clear + + Focus on work inside a specific function: + pq thread functions --search PresentImpl Note the handle (e.g. f-500) + pq thread samples --root-at f-500 Ephemeral: subtree rooted at f-500 + pq filter push --includes-function f-500 Sticky: only samples containing f-500 + pq filter push --root-at f-500 Sticky: re-root at f-500 + pq thread samples-top-down Call tree within f-500 only + + Correlate CPU with a specific event type: + pq thread markers --search Paint Check Paint marker frequency/duration + pq filter push --during-marker --search Paint + pq thread samples What runs during Paint? + pq thread functions Which functions are active during Paint? + pq filter clear + Find slow layout or script execution: pq thread markers --category Layout --min-duration 5 pq thread markers --category JavaScript --min-duration 10 @@ -169,11 +269,16 @@ TIPS - Use --search to narrow the call tree to branches you care about: --search GC shows only call paths that pass through a GC-related function - Zoom into slow periods: use "zoom push m-" to focus on a slow marker + - Try filters ephemerally first (as flags on thread commands) before committing with "filter push" + - "filter push --merge" is great for hiding allocator/profiler noise without losing context + - "filter push --root-at f-N" + "filter push --includes-function f-N" tightly focuses + analysis on one function's work + - "filter push --during-marker --search X" is powerful for correlating CPU work with events + - Sticky filters compose: each push narrows further (AND semantics across pushes) + - "filter list" shows filters in push order; "filter pop" removes from the end - Use --json when you need to process or filter results programmatically - - Marker handles are stable: you can refer back to m-1234 multiple times - - JS vs native: JS functions show source locations; native functions show C++/Rust names - - Category filters: use --category with values like Layout, JavaScript, Network, GC, etc. - - Check "status": run "pq status" to confirm selected thread and active zoom ranges + - Marker handles are stable within a session; function handles (f-N) are stable across sessions + - Check "status": run "pq status" to confirm selected thread, active zoom, and active filters SESSION MANAGEMENT diff --git a/profile-query-cli/src/daemon.ts b/profile-query-cli/src/daemon.ts index 04f6ebebf0..f14ee5c13e 100644 --- a/profile-query-cli/src/daemon.ts +++ b/profile-query-cli/src/daemon.ts @@ -300,21 +300,24 @@ export class Daemon { return this.querier!.threadSamples( command.thread, command.includeIdle, - command.search + command.search, + command.sampleFilters ); case 'samples-top-down': return this.querier!.threadSamplesTopDown( command.thread, command.callTreeOptions, command.includeIdle, - command.search + command.search, + command.sampleFilters ); case 'samples-bottom-up': return this.querier!.threadSamplesBottomUp( command.thread, command.callTreeOptions, command.includeIdle, - command.search + command.search, + command.sampleFilters ); case 'markers': return this.querier!.threadMarkers( @@ -325,7 +328,8 @@ export class Daemon { return this.querier!.threadFunctions( command.thread, command.functionFilters, - command.includeIdle + command.includeIdle, + command.sampleFilters ); default: throw assertExhaustiveCheck(command); @@ -387,6 +391,22 @@ export class Daemon { default: throw assertExhaustiveCheck(command); } + case 'filter': + switch (command.subcommand) { + case 'push': + if (!command.spec) { + throw new Error('spec is required for filter push'); + } + return this.querier!.filterPush(command.spec, command.thread); + case 'pop': + return this.querier!.filterPop(command.count ?? 1, command.thread); + case 'list': + return this.querier!.filterList(command.thread); + case 'clear': + return this.querier!.filterClear(command.thread); + default: + throw assertExhaustiveCheck(command); + } case 'status': return this.querier!.getStatus(); default: diff --git a/profile-query-cli/src/formatters.ts b/profile-query-cli/src/formatters.ts index 1d74d0e446..473cee7ee0 100644 --- a/profile-query-cli/src/formatters.ts +++ b/profile-query-cli/src/formatters.ts @@ -14,6 +14,7 @@ import type { FunctionExpandResult, FunctionInfoResult, ViewRangeResult, + FilterStackResult, ThreadInfoResult, MarkerStackResult, MarkerInfoResult, @@ -25,14 +26,21 @@ import type { ThreadFunctionsResult, MarkerGroupData, CallTreeNode, + FilterEntry, + SampleFilterSpec, } from './protocol'; import { truncateFunctionName } from '../../src/profile-query/function-list'; +import { describeSpec } from '../../src/profile-query/filter-stack'; /** * Format a SessionContext as a compact header line. * Shows current thread selection, zoom range, and full profile duration. */ -export function formatContextHeader(context: SessionContext): string { +export function formatContextHeader( + context: SessionContext, + activeFilters?: FilterEntry[], + ephemeralFilters?: SampleFilterSpec[] +): string { // Thread info let threadInfo = 'No thread selected'; if (context.selectedThreadHandle && context.selectedThreads.length > 0) { @@ -65,7 +73,11 @@ export function formatContextHeader(context: SessionContext): string { const fullInfo = formatDuration(rootDuration); - return `[Thread: ${threadInfo} | View: ${viewInfo} | Full: ${fullInfo}]`; + const totalFilterCount = + (activeFilters?.length ?? 0) + (ephemeralFilters?.length ?? 0); + const filterInfo = + totalFilterCount > 0 ? ` | Filters: ${totalFilterCount}` : ''; + return `[Thread: ${threadInfo} | View: ${viewInfo} | Full: ${fullInfo}${filterInfo}]`; } /** @@ -91,10 +103,42 @@ export function formatStatusResult(result: StatusResult): string { rangesInfo = rangeStrs.join(' > '); } + const filterLines: string[] = []; + for (const stack of result.filterStacks) { + if (stack.filters.length === 0) continue; + filterLines.push(` Filters for ${stack.threadHandle}:`); + for (const f of stack.filters) { + filterLines.push(` ${f.index}. [${f.spec.type}] ${f.description}`); + } + } + const filterSection = + filterLines.length > 0 + ? '\n' + filterLines.join('\n') + : '\n Filters: none'; + return `\ Session Status: Selected thread: ${threadInfo} - View range: ${rangesInfo}`; + View range: ${rangesInfo}${filterSection}`; +} + +/** + * Format a FilterStackResult as plain text. + */ +export function formatFilterStackResult(result: FilterStackResult): string { + const lines: string[] = []; + if (result.message) { + lines.push(result.message); + } + if (result.filters.length === 0) { + lines.push(`No active filters for ${result.threadHandle}`); + } else { + lines.push(`Filters for ${result.threadHandle} (applied in order):`); + for (const f of result.filters) { + lines.push(` ${f.index}. [${f.spec.type}] ${f.description}`); + } + } + return lines.join('\n'); } /** @@ -492,14 +536,25 @@ function formatCallTree(tree: CallTreeNode, title: string): string { export function formatThreadSamplesResult( result: WithContext ): string { - const contextHeader = formatContextHeader(result.context); + const contextHeader = formatContextHeader( + result.context, + result.activeFilters, + result.ephemeralFilters + ); const activeOnlyNote = result.activeOnly ? 'Note: active samples only (idle excluded) — use --include-idle to include idle samples.\n\n' : ''; const searchNote = result.search ? `Search: "${result.search}"\n\n` : ''; + const filtersParts: string[] = [ + ...(result.activeFilters?.map((f) => `[${f.index}] ${f.description}`) ?? + []), + ...(result.ephemeralFilters?.map((f) => `[~] ${describeSpec(f)}`) ?? []), + ]; + const filtersNote = + filtersParts.length > 0 ? `Filters: ${filtersParts.join(', ')}\n\n` : ''; let output = `${contextHeader} -Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}`; +Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}${filtersNote}`; // Top functions by total time output += 'Top Functions (by total time):\n'; @@ -581,14 +636,25 @@ Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}`; export function formatThreadSamplesTopDownResult( result: WithContext ): string { - const contextHeader = formatContextHeader(result.context); + const contextHeader = formatContextHeader( + result.context, + result.activeFilters, + result.ephemeralFilters + ); const activeOnlyNote = result.activeOnly ? 'Note: active samples only (idle excluded) — use --include-idle to include idle samples.\n\n' : ''; const searchNote = result.search ? `Search: "${result.search}"\n\n` : ''; + const filtersParts: string[] = [ + ...(result.activeFilters?.map((f) => `[${f.index}] ${f.description}`) ?? + []), + ...(result.ephemeralFilters?.map((f) => `[~] ${describeSpec(f)}`) ?? []), + ]; + const filtersNote = + filtersParts.length > 0 ? `Filters: ${filtersParts.join(', ')}\n\n` : ''; let output = `${contextHeader} -Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}`; +Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}${filtersNote}`; // Top-down call tree output += formatCallTree(result.regularCallTree, 'Top-Down'); @@ -602,14 +668,25 @@ Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}`; export function formatThreadSamplesBottomUpResult( result: WithContext ): string { - const contextHeader = formatContextHeader(result.context); + const contextHeader = formatContextHeader( + result.context, + result.activeFilters, + result.ephemeralFilters + ); const activeOnlyNote = result.activeOnly ? 'Note: active samples only (idle excluded) — use --include-idle to include idle samples.\n\n' : ''; const searchNote = result.search ? `Search: "${result.search}"\n\n` : ''; + const filtersParts: string[] = [ + ...(result.activeFilters?.map((f) => `[${f.index}] ${f.description}`) ?? + []), + ...(result.ephemeralFilters?.map((f) => `[~] ${describeSpec(f)}`) ?? []), + ]; + const filtersNote = + filtersParts.length > 0 ? `Filters: ${filtersParts.join(', ')}\n\n` : ''; let output = `${contextHeader} -Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}`; +Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}${filtersNote}`; // Bottom-up call tree (inverted tree shows callers) if (result.invertedCallTree) { @@ -795,7 +872,11 @@ function formatDuration(ms: number): string { export function formatThreadFunctionsResult( result: WithContext ): string { - const contextHeader = formatContextHeader(result.context); + const contextHeader = formatContextHeader( + result.context, + result.activeFilters, + result.ephemeralFilters + ); const lines: string[] = [contextHeader, '']; // Check if filters are active @@ -825,8 +906,8 @@ export function formatThreadFunctionsResult( } // Show active filters if any + const filterParts: string[] = []; if (hasFilters && result.filters) { - const filterParts: string[] = []; if (result.filters.searchString) { filterParts.push(`search: "${result.filters.searchString}"`); } @@ -836,10 +917,20 @@ export function formatThreadFunctionsResult( if (result.filters.limit !== undefined) { filterParts.push(`limit: ${result.filters.limit}`); } - if (filterParts.length > 0) { - lines.push(`Filters: ${filterParts.join(', ')}\n`); + } + if (result.activeFilters) { + for (const f of result.activeFilters) { + filterParts.push(`[${f.index}] ${f.description}`); + } + } + if (result.ephemeralFilters) { + for (const f of result.ephemeralFilters) { + filterParts.push(`[~] ${describeSpec(f)}`); } } + if (filterParts.length > 0) { + lines.push(`Filters: ${filterParts.join(', ')}\n`); + } // List functions sorted by self time lines.push('Functions (by self time):'); diff --git a/profile-query-cli/src/index.ts b/profile-query-cli/src/index.ts index b3f77f2994..a5cb54d270 100644 --- a/profile-query-cli/src/index.ts +++ b/profile-query-cli/src/index.ts @@ -37,6 +37,7 @@ import { cleanupSession, listSessions, validateSession } from './session'; import type { MarkerFilterOptions, FunctionFilterOptions, + SampleFilterSpec, CommandResult, CallTreeCollectionOptions, CallTreeScoringStrategy, @@ -46,6 +47,7 @@ import { formatFunctionExpandResult, formatFunctionInfoResult, formatViewRangeResult, + formatFilterStackResult, formatThreadInfoResult, formatMarkerStackResult, formatMarkerInfoResult, @@ -71,6 +73,20 @@ interface Args { json?: boolean; 'max-lines'?: number; scoring?: string; + // filter command flags (value-taking) + 'excludes-function'?: string; + 'excludes-any-function'?: string; + merge?: string; + 'root-at'?: string; + 'includes-function'?: string; + 'includes-any-function'?: string; + 'includes-prefix'?: string; + 'includes-suffix'?: string; + count?: string; + // filter command presence flags + 'during-marker'?: boolean; + 'outside-marker'?: boolean; + search?: string; } function printUsage(): void { @@ -93,7 +109,11 @@ Commands: zoom push Push a zoom range (e.g., 2.7,3.1 or ts-g,ts-G or m-158) zoom pop Pop the most recent zoom range zoom clear Clear all zoom ranges (return to full profile) - status Show session status (selected thread, zoom ranges) + filter push Push a sticky sample filter (see filter flags below) + filter pop [N] Pop the last N filters (default: 1) + filter list List active filters for current thread + filter clear Remove all filters for current thread + status Show session status (selected thread, zoom ranges, filters) stop Stop the daemon session list-sessions List all running daemon sessions @@ -120,6 +140,22 @@ Options: --guide Show detailed usage guide (commands, patterns, tips) --help, -h Show this help message +Sample filter flags (work on 'thread samples/functions' ephemerally AND with 'filter push' for sticky use): + --excludes-function Drop samples containing this function + --excludes-any-function Drop samples containing any of these functions (OR) + --merge Merge (remove) functions from stacks + --root-at Re-root stacks at this function + --includes-function Keep only samples whose stack contains any of these functions + --includes-any-function Alias for --includes-function (explicit OR form) + --includes-prefix Keep only samples whose stack starts with this root-first sequence + --includes-suffix Keep only samples whose leaf frame is this function + --during-marker --search Keep only samples during matching markers + --outside-marker --search Keep only samples outside matching markers + + For 'filter push': exactly one filter flag per push. + For 'thread samples/functions': multiple flags may be combined; applied in left-to-right order. + The same flag may also be repeated (e.g. --merge f-1 --merge f-2) to apply it multiple times. + Examples: pq load profile.json.gz pq profile info @@ -136,13 +172,9 @@ Examples: pq thread markers --max-duration 100 pq thread markers --has-stack pq thread markers --limit 1000 - pq thread markers --category Layout --min-duration 5 - pq thread markers --search Reflow --min-duration 5 --max-duration 50 - pq thread markers --has-stack --category Other --limit 500 pq thread markers --group-by type,name pq thread markers --group-by type,field:eventType pq thread markers --auto-group - pq thread markers --search DOMEvent --group-by field:eventType pq marker info m-1234 pq marker stack m-1234 pq function expand f-123 @@ -151,6 +183,19 @@ Examples: pq zoom push m-158 pq zoom pop pq zoom clear + pq filter push --excludes-function f-184 + pq filter push --merge f-142,f-143 + pq filter push --includes-function f-500 + pq filter push --during-marker --search Paint + pq filter push --outside-marker --search GC + pq filter pop + pq filter pop 3 + pq filter list + pq filter clear + pq thread samples --excludes-function f-184 + pq thread samples --merge f-1 --merge f-2 --root-at f-500 + pq thread samples --root-at f-500 --limit 30 + pq thread functions --includes-function f-500 --limit 20 pq status pq stop pq list-sessions @@ -158,7 +203,6 @@ Examples: pq thread samples-top-down --scoring exponential-0.8 pq thread samples-top-down --search GC pq thread samples-bottom-up --max-lines 200 --scoring harmonic-1.0 - pq thread samples-bottom-up --search "JS::Compile" pq thread samples --include-idle `); } @@ -193,6 +237,8 @@ function formatOutput( switch (result.type) { case 'status': return formatStatusResult(result); + case 'filter-stack': + return formatFilterStackResult(result); case 'function-expand': return formatFunctionExpandResult(result); case 'function-info': @@ -223,6 +269,207 @@ function formatOutput( } } +/** + * Parse zero or more ephemeral SampleFilterSpecs from CLI arguments. + * Used for `pq thread samples/functions` etc. to apply filters for one invocation. + * Multiple flags are collected in order; each produces one spec. The same flag may be + * repeated (e.g. --merge f-1 --merge f-2) to apply it multiple times. + */ +function parseEphemeralFilters(argv: Args): SampleFilterSpec[] { + function parseFuncList(value: string): number[] { + return value.split(',').map((s) => { + const m = /^f-(\d+)$/.exec(s.trim()); + if (!m) { + console.error( + `Error: invalid function handle "${s.trim()}" (expected f-)` + ); + process.exit(1); + } + return parseInt(m[1], 10); + }); + } + + // Normalize a flag value to an array to support repeated flags (e.g. --merge x --merge y). + function toArray(value: unknown): string[] { + if (value === undefined) return []; + return Array.isArray(value) ? value : [value as string]; + } + + const specs: SampleFilterSpec[] = []; + + for (const v of toArray(argv['excludes-function'])) { + specs.push({ type: 'excludes-function', funcIndexes: parseFuncList(v) }); + } + for (const v of toArray(argv['excludes-any-function'])) { + specs.push({ type: 'excludes-function', funcIndexes: parseFuncList(v) }); + } + for (const v of toArray(argv.merge)) { + specs.push({ type: 'merge', funcIndexes: parseFuncList(v) }); + } + for (const v of toArray(argv['root-at'])) { + const indexes = parseFuncList(v); + if (indexes.length !== 1) { + console.error('Error: --root-at takes exactly one function handle'); + process.exit(1); + } + specs.push({ type: 'root-at', funcIndex: indexes[0] }); + } + for (const v of toArray(argv['includes-function'])) { + specs.push({ type: 'includes-function', funcIndexes: parseFuncList(v) }); + } + for (const v of toArray(argv['includes-any-function'])) { + specs.push({ type: 'includes-function', funcIndexes: parseFuncList(v) }); + } + for (const v of toArray(argv['includes-prefix'])) { + specs.push({ type: 'includes-prefix', funcIndexes: parseFuncList(v) }); + } + for (const v of toArray(argv['includes-suffix'])) { + const indexes = parseFuncList(v); + if (indexes.length !== 1) { + console.error( + 'Error: --includes-suffix takes exactly one function handle' + ); + process.exit(1); + } + specs.push({ type: 'includes-suffix', funcIndex: indexes[0] }); + } + if (argv['during-marker'] === true) { + if (!argv.search) { + console.error('Error: --during-marker requires --search '); + process.exit(1); + } + specs.push({ type: 'during-marker', searchString: argv.search }); + } + if (argv['outside-marker'] === true) { + if (!argv.search) { + console.error('Error: --outside-marker requires --search '); + process.exit(1); + } + specs.push({ type: 'outside-marker', searchString: argv.search }); + } + + return specs; +} + +/** + * Parse a SampleFilterSpec from CLI arguments for `pq filter push`. + * Exactly one filter flag must be provided. + */ +function parseFilterSpec(argv: Args): SampleFilterSpec { + // Parse a comma-separated list of function handles (e.g. "f-1,f-2") into funcIndexes. + function parseFuncList(value: string): number[] { + return value.split(',').map((s) => { + const m = /^f-(\d+)$/.exec(s.trim()); + if (!m) { + console.error( + `Error: invalid function handle "${s.trim()}" (expected f-)` + ); + process.exit(1); + } + return parseInt(m[1], 10); + }); + } + + const flags = [ + 'excludes-function', + 'excludes-any-function', + 'merge', + 'root-at', + 'includes-function', + 'includes-any-function', + 'includes-prefix', + 'includes-suffix', + ] as const; + + const markerFlags = ['during-marker', 'outside-marker'] as const; + const activeFlags = flags.filter((f) => argv[f] !== undefined); + const activeMarkerFlags = markerFlags.filter((f) => argv[f] === true); + + const totalActive = activeFlags.length + activeMarkerFlags.length; + if (totalActive === 0) { + console.error( + 'Error: filter push requires one of: ' + + [...flags, ...markerFlags].map((f) => `--${f}`).join(', ') + ); + process.exit(1); + } + if (totalActive > 1) { + console.error('Error: filter push accepts only one filter flag per push'); + process.exit(1); + } + + if (argv['excludes-function'] !== undefined) { + return { + type: 'excludes-function', + funcIndexes: parseFuncList(argv['excludes-function']), + }; + } + if (argv['excludes-any-function'] !== undefined) { + return { + type: 'excludes-function', + funcIndexes: parseFuncList(argv['excludes-any-function']), + }; + } + if (argv.merge !== undefined) { + return { type: 'merge', funcIndexes: parseFuncList(argv.merge) }; + } + if (argv['root-at'] !== undefined) { + const indexes = parseFuncList(argv['root-at']); + if (indexes.length !== 1) { + console.error('Error: --root-at takes exactly one function handle'); + process.exit(1); + } + return { type: 'root-at', funcIndex: indexes[0] }; + } + if (argv['includes-function'] !== undefined) { + return { + type: 'includes-function', + funcIndexes: parseFuncList(argv['includes-function']), + }; + } + if (argv['includes-any-function'] !== undefined) { + return { + type: 'includes-function', + funcIndexes: parseFuncList(argv['includes-any-function']), + }; + } + if (argv['includes-prefix'] !== undefined) { + return { + type: 'includes-prefix', + funcIndexes: parseFuncList(argv['includes-prefix']), + }; + } + if (argv['includes-suffix'] !== undefined) { + const indexes = parseFuncList(argv['includes-suffix']); + if (indexes.length !== 1) { + console.error( + 'Error: --includes-suffix takes exactly one function handle' + ); + process.exit(1); + } + return { type: 'includes-suffix', funcIndex: indexes[0] }; + } + if (argv['during-marker'] === true) { + if (!argv.search) { + console.error('Error: --during-marker requires --search '); + process.exit(1); + } + return { type: 'during-marker', searchString: argv.search }; + } + if (argv['outside-marker'] === true) { + if (!argv.search) { + console.error('Error: --outside-marker requires --search '); + process.exit(1); + } + return { type: 'outside-marker', searchString: argv.search }; + } + + // Should not be reachable. + console.error('Error: no valid filter flag found'); + process.exit(1); + throw new Error('unreachable'); +} + async function main(): Promise { const argv = minimist(process.argv.slice(2), { string: [ @@ -240,6 +487,16 @@ async function main(): Promise { 'group-by', 'max-lines', 'scoring', + // filter command flags (value-taking) + 'excludes-function', + 'excludes-any-function', + 'merge', + 'root-at', + 'includes-function', + 'includes-any-function', + 'includes-prefix', + 'includes-suffix', + 'count', ], boolean: [ 'daemon', @@ -250,6 +507,9 @@ async function main(): Promise { 'has-stack', 'auto-group', 'json', + // filter command presence flags + 'during-marker', + 'outside-marker', ], alias: { h: 'help' }, }); @@ -508,6 +768,15 @@ async function main(): Promise { subcommand === 'markers' || subcommand === 'functions' ) { + // Parse ephemeral sample filters for commands that support them. + const sampleFilters = + subcommand === 'samples' || + subcommand === 'samples-top-down' || + subcommand === 'samples-bottom-up' || + subcommand === 'functions' + ? parseEphemeralFilters(argv) + : undefined; + const result = await sendCommand( SESSION_DIR, { @@ -519,6 +788,7 @@ async function main(): Promise { markerFilters, functionFilters, callTreeOptions, + sampleFilters: sampleFilters?.length ? sampleFilters : undefined, }, argv.session ); @@ -603,6 +873,60 @@ async function main(): Promise { break; } + case 'filter': { + const explicitSubcommand = argv._[1]; + const subcommand = explicitSubcommand ?? 'list'; + + const thread = argv.thread ?? undefined; + + if (subcommand === 'push') { + // Parse the filter spec from flags. + const spec = parseFilterSpec(argv); + const result = await sendCommand( + SESSION_DIR, + { command: 'filter', subcommand: 'push', thread, spec }, + argv.session + ); + console.log(formatOutput(result, argv.json || false)); + } else if (subcommand === 'pop') { + // Accept count as positional arg ("pq filter pop 2") or --count flag. + const rawCount = argv._[2] ?? argv.count; + const count = + rawCount !== undefined ? parseInt(String(rawCount), 10) : 1; + if (isNaN(count) || count <= 0) { + console.error('Error: count must be a positive integer'); + process.exit(1); + } + const result = await sendCommand( + SESSION_DIR, + { command: 'filter', subcommand: 'pop', thread, count }, + argv.session + ); + console.log(formatOutput(result, argv.json || false)); + } else if (subcommand === 'list') { + const result = await sendCommand( + SESSION_DIR, + { command: 'filter', subcommand: 'list', thread }, + argv.session + ); + console.log(formatOutput(result, argv.json || false)); + if (!explicitSubcommand) { + console.log('Other options: pq filter [options]'); + } + } else if (subcommand === 'clear') { + const result = await sendCommand( + SESSION_DIR, + { command: 'filter', subcommand: 'clear', thread }, + argv.session + ); + console.log(formatOutput(result, argv.json || false)); + } else { + console.error(`Error: Unknown command filter ${subcommand}`); + process.exit(1); + } + break; + } + case 'zoom': { const subcommand = argv._[1]; if (!subcommand) { diff --git a/profile-query-cli/src/protocol.ts b/profile-query-cli/src/protocol.ts index 8d852103cb..1a9a106c55 100644 --- a/profile-query-cli/src/protocol.ts +++ b/profile-query-cli/src/protocol.ts @@ -11,6 +11,9 @@ export type { MarkerFilterOptions, FunctionFilterOptions, + SampleFilterSpec, + FilterEntry, + FilterStackResult, SessionContext, WithContext, StatusResult, @@ -39,6 +42,7 @@ export type { CallTreeCollectionOptions } from '../../src/profile-query/formatte import type { MarkerFilterOptions, FunctionFilterOptions, + SampleFilterSpec, WithContext, StatusResult, FunctionExpandResult, @@ -53,6 +57,7 @@ import type { ThreadSamplesBottomUpResult, ThreadMarkersResult, ThreadFunctionsResult, + FilterStackResult, } from '../../src/profile-query/types'; import type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; @@ -84,6 +89,8 @@ export type ClientCommand = markerFilters?: MarkerFilterOptions; functionFilters?: FunctionFilterOptions; callTreeOptions?: CallTreeCollectionOptions; + /** Ephemeral sample filters applied only to this command invocation */ + sampleFilters?: SampleFilterSpec[]; } | { command: 'marker'; @@ -101,6 +108,13 @@ export type ClientCommand = subcommand: 'push' | 'pop' | 'clear'; range?: string; } + | { + command: 'filter'; + subcommand: 'push' | 'pop' | 'list' | 'clear'; + thread?: string; + spec?: SampleFilterSpec; + count?: number; + } | { command: 'status' }; export type ServerResponse = @@ -118,6 +132,7 @@ export type CommandResult = | WithContext | WithContext | ViewRangeResult + | FilterStackResult | WithContext | WithContext | WithContext diff --git a/src/profile-query/filter-stack.ts b/src/profile-query/filter-stack.ts new file mode 100644 index 0000000000..023884b19f --- /dev/null +++ b/src/profile-query/filter-stack.ts @@ -0,0 +1,234 @@ +/* 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/. */ + +/** + * FilterStack manages the per-thread sample filter stacks for the pq CLI. + * + * Each thread (identified by its ThreadsKey) has an independent stack of filter + * entries. Each entry corresponds to one `pq filter push` invocation and may + * have dispatched one or more Redux transforms to the store. + * + * Popping entries removes both the in-memory records and the corresponding Redux + * transforms, using the POP_TRANSFORMS_FROM_STACK action. + */ + +import { getTransformStack } from '../selectors/url-state'; +import { addTransformToStack } from '../actions/profile-view'; +import type { SampleFilterSpec, FilterEntry } from './types'; +import type { Store } from '../types/store'; +import type { ThreadsKey } from 'firefox-profiler/types'; + +/** + * Build a human-readable description for a filter spec. + */ +export function describeSpec(spec: SampleFilterSpec): string { + switch (spec.type) { + case 'excludes-function': + return `excludes function: f-${spec.funcIndexes.join(', f-')}`; + case 'merge': + return `merge: f-${spec.funcIndexes.join(', f-')}`; + case 'root-at': + return `root-at: f-${spec.funcIndex}`; + case 'during-marker': + return `during marker matching: "${spec.searchString}"`; + case 'includes-function': + return `includes function: f-${spec.funcIndexes.join(', f-')}`; + case 'includes-prefix': + return `includes prefix: f-${spec.funcIndexes.join(' → f-')}`; + case 'includes-suffix': + return `includes suffix: f-${spec.funcIndex}`; + case 'outside-marker': + return `outside marker matching: "${spec.searchString}"`; + default: + throw new Error( + `Unhandled filter spec type: ${(spec as SampleFilterSpec).type}` + ); + } +} + +/** + * Push the Redux transforms for a filter spec and return the number pushed. + * Exported so the ProfileQuerier can use it for ephemeral (non-tracked) filters. + */ +export function pushSpecTransforms( + store: Store, + threadsKey: ThreadsKey, + spec: SampleFilterSpec +): number { + switch (spec.type) { + case 'excludes-function': { + for (const funcIndex of spec.funcIndexes) { + store.dispatch( + addTransformToStack(threadsKey, { type: 'drop-function', funcIndex }) + ); + } + return spec.funcIndexes.length; + } + case 'merge': { + for (const funcIndex of spec.funcIndexes) { + store.dispatch( + addTransformToStack(threadsKey, { type: 'merge-function', funcIndex }) + ); + } + return spec.funcIndexes.length; + } + case 'root-at': { + store.dispatch( + addTransformToStack(threadsKey, { + type: 'focus-function', + funcIndex: spec.funcIndex, + }) + ); + return 1; + } + case 'during-marker': { + store.dispatch( + addTransformToStack(threadsKey, { + type: 'filter-samples', + filterType: 'marker-search', + filter: spec.searchString, + }) + ); + return 1; + } + case 'includes-function': { + store.dispatch( + addTransformToStack(threadsKey, { + type: 'filter-samples', + filterType: 'function-include', + filter: spec.funcIndexes.join(','), + }) + ); + return 1; + } + case 'includes-prefix': { + store.dispatch( + addTransformToStack(threadsKey, { + type: 'filter-samples', + filterType: 'stack-prefix', + filter: spec.funcIndexes.join(','), + }) + ); + return 1; + } + case 'includes-suffix': { + store.dispatch( + addTransformToStack(threadsKey, { + type: 'filter-samples', + filterType: 'stack-suffix', + filter: String(spec.funcIndex), + }) + ); + return 1; + } + case 'outside-marker': { + store.dispatch( + addTransformToStack(threadsKey, { + type: 'filter-samples', + filterType: 'outside-marker', + filter: spec.searchString, + }) + ); + return 1; + } + default: + throw new Error( + `Unhandled filter spec type: ${(spec as SampleFilterSpec).type}` + ); + } +} + +export class FilterStack { + /** Per-thread filter entries. Key is the ThreadsKey. */ + private _stacks: Map = new Map(); + + /** + * Push a new filter entry for the given thread. + * Dispatches the necessary Redux transforms immediately. + */ + push( + store: Store, + threadsKey: ThreadsKey, + spec: SampleFilterSpec + ): FilterEntry { + const entries = this._getOrCreate(threadsKey); + const reduxTransformCount = pushSpecTransforms(store, threadsKey, spec); + const entry: FilterEntry = { + index: entries.length + 1, + spec, + description: describeSpec(spec), + reduxTransformCount, + }; + entries.push(entry); + return entry; + } + + /** + * Pop the last `count` filter entries for the given thread. + * Dispatches POP_TRANSFORMS_FROM_STACK to remove the corresponding Redux transforms. + * Returns the removed entries (most recent first). + */ + pop(store: Store, threadsKey: ThreadsKey, count: number = 1): FilterEntry[] { + const entries = this._getOrCreate(threadsKey); + if (count <= 0 || entries.length === 0) { + return []; + } + const actualCount = Math.min(count, entries.length); + const removed = entries.splice(entries.length - actualCount, actualCount); + + // Compute how many Redux transforms need to be popped. + const totalReduxPops = removed.reduce( + (sum, e) => sum + e.reduxTransformCount, + 0 + ); + if (totalReduxPops > 0) { + const state = store.getState(); + const currentLength = getTransformStack(state, threadsKey).length; + store.dispatch({ + type: 'POP_TRANSFORMS_FROM_STACK', + threadsKey, + firstPoppedFilterIndex: currentLength - totalReduxPops, + }); + } + + return removed.reverse(); + } + + /** + * Clear all filter entries for the given thread. + */ + clear(store: Store, threadsKey: ThreadsKey): FilterEntry[] { + const entries = this._getOrCreate(threadsKey); + const count = entries.length; + if (count === 0) { + return []; + } + return this.pop(store, threadsKey, count); + } + + /** + * Return all filter entries for the given thread (a snapshot, not live reference). + */ + list(threadsKey: ThreadsKey): FilterEntry[] { + return [...(this._stacks.get(threadsKey) ?? [])]; + } + + /** + * Return all thread keys that have at least one active filter. + */ + activeThreadsKeys(): ThreadsKey[] { + return Array.from(this._stacks.entries()) + .filter(([, entries]) => entries.length > 0) + .map(([key]) => key); + } + + private _getOrCreate(threadsKey: ThreadsKey): FilterEntry[] { + let entries = this._stacks.get(threadsKey); + if (entries === undefined) { + entries = []; + this._stacks.set(threadsKey, entries); + } + return entries; + } +} diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 72e0e97d32..03b1dca41c 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -55,6 +55,7 @@ import { collectMarkerInfo, } from './formatters/marker-info'; import { parseTimeValue } from './time-range-parser'; +import { FilterStack, pushSpecTransforms } from './filter-stack'; import type { StatusResult, SessionContext, @@ -73,6 +74,8 @@ import type { ThreadFunctionsResult, MarkerFilterOptions, FunctionFilterOptions, + SampleFilterSpec, + FilterStackResult, } from './types'; import type { CallTreeCollectionOptions } from './formatters/call-tree'; @@ -86,12 +89,14 @@ export class ProfileQuerier { _timestampManager: TimestampManager; _threadMap: ThreadMap; _markerMap: MarkerMap; + _filterStack: FilterStack; constructor(store: Store, rootRange: StartEndRange) { this._store = store; this._processIndexMap = new Map(); this._timestampManager = new TimestampManager(rootRange); this._threadMap = new ThreadMap(); + this._filterStack = new FilterStack(); // Build process index map const state = this._store.getState(); @@ -138,7 +143,8 @@ export class ProfileQuerier { async threadSamples( threadHandle?: string, includeIdle: boolean = false, - search?: string + search?: string, + sampleFilters?: SampleFilterSpec[] ): Promise> { const activeOnly = !includeIdle; const threadIndexes = @@ -150,13 +156,21 @@ export class ProfileQuerier { const withIdle = activeOnly ? () => this._withDroppedIdle(threadIndexes, collect) : collect; - const result = search - ? this._withCallTreeSearch(search, withIdle) - : withIdle(); + const withSearch = search + ? () => this._withCallTreeSearch(search, withIdle) + : withIdle; + const result = + sampleFilters && sampleFilters.length > 0 + ? this._withEphemeralFilters(threadIndexes, sampleFilters, withSearch) + : withSearch(); + const activeFilters = this._filterStack.list(getThreadsKey(threadIndexes)); return { ...result, activeOnly, search: search || undefined, + activeFilters: activeFilters.length > 0 ? activeFilters : undefined, + ephemeralFilters: + sampleFilters && sampleFilters.length > 0 ? sampleFilters : undefined, context: this._getContext(), }; } @@ -165,7 +179,8 @@ export class ProfileQuerier { threadHandle?: string, callTreeOptions?: CallTreeCollectionOptions, includeIdle: boolean = false, - search?: string + search?: string, + sampleFilters?: SampleFilterSpec[] ): Promise> { const activeOnly = !includeIdle; const threadIndexes = @@ -182,13 +197,21 @@ export class ProfileQuerier { const withIdle = activeOnly ? () => this._withDroppedIdle(threadIndexes, collect) : collect; - const result = search - ? this._withCallTreeSearch(search, withIdle) - : withIdle(); + const withSearch = search + ? () => this._withCallTreeSearch(search, withIdle) + : withIdle; + const result = + sampleFilters && sampleFilters.length > 0 + ? this._withEphemeralFilters(threadIndexes, sampleFilters, withSearch) + : withSearch(); + const activeFilters = this._filterStack.list(getThreadsKey(threadIndexes)); return { ...result, activeOnly, search: search || undefined, + activeFilters: activeFilters.length > 0 ? activeFilters : undefined, + ephemeralFilters: + sampleFilters && sampleFilters.length > 0 ? sampleFilters : undefined, context: this._getContext(), }; } @@ -197,7 +220,8 @@ export class ProfileQuerier { threadHandle?: string, callTreeOptions?: CallTreeCollectionOptions, includeIdle: boolean = false, - search?: string + search?: string, + sampleFilters?: SampleFilterSpec[] ): Promise> { const activeOnly = !includeIdle; const threadIndexes = @@ -214,13 +238,21 @@ export class ProfileQuerier { const withIdle = activeOnly ? () => this._withDroppedIdle(threadIndexes, collect) : collect; - const result = search - ? this._withCallTreeSearch(search, withIdle) - : withIdle(); + const withSearch = search + ? () => this._withCallTreeSearch(search, withIdle) + : withIdle; + const result = + sampleFilters && sampleFilters.length > 0 + ? this._withEphemeralFilters(threadIndexes, sampleFilters, withSearch) + : withSearch(); + const activeFilters = this._filterStack.list(getThreadsKey(threadIndexes)); return { ...result, activeOnly, search: search || undefined, + activeFilters: activeFilters.length > 0 ? activeFilters : undefined, + ephemeralFilters: + sampleFilters && sampleFilters.length > 0 ? sampleFilters : undefined, context: this._getContext(), }; } @@ -450,6 +482,143 @@ export class ProfileQuerier { return `Selected ${threadIndexes.size} threads: ${threadHandle} (${names})`; } + /** + * Push a new filter onto the current thread's filter stack. + */ + filterPush(spec: SampleFilterSpec, threadHandle?: string): FilterStackResult { + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const threadsKey = getThreadsKey(threadIndexes); + const actualHandle = + threadHandle ?? this._threadMap.handleForThreadIndexes(threadIndexes); + + const entry = this._filterStack.push(this._store, threadsKey, spec); + const filters = this._filterStack.list(threadsKey); + + return { + type: 'filter-stack', + threadHandle: actualHandle, + filters, + action: 'push', + message: `Pushed filter ${entry.index}: ${entry.description}`, + }; + } + + /** + * Pop the last `count` filters from the current thread's filter stack. + */ + filterPop(count: number = 1, threadHandle?: string): FilterStackResult { + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const threadsKey = getThreadsKey(threadIndexes); + const actualHandle = + threadHandle ?? this._threadMap.handleForThreadIndexes(threadIndexes); + + const removed = this._filterStack.pop(this._store, threadsKey, count); + const filters = this._filterStack.list(threadsKey); + + let msg; + if (removed.length === 0) { + msg = 'No filters to pop'; + } else if (removed.length === 1) { + msg = `Popped filter: ${removed[0].description}`; + } else { + msg = `Popped ${removed.length} filters`; + } + + return { + type: 'filter-stack', + threadHandle: actualHandle, + filters, + action: 'pop', + message: msg, + }; + } + + /** + * Clear all filters from the current thread's filter stack. + */ + filterClear(threadHandle?: string): FilterStackResult { + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const threadsKey = getThreadsKey(threadIndexes); + const actualHandle = + threadHandle ?? this._threadMap.handleForThreadIndexes(threadIndexes); + + const removed = this._filterStack.clear(this._store, threadsKey); + const msg = + removed.length === 0 + ? 'No filters to clear' + : `Cleared ${removed.length} filter(s)`; + + return { + type: 'filter-stack', + threadHandle: actualHandle, + filters: [], + action: 'clear', + message: msg, + }; + } + + /** + * List all active filters for the current thread. + */ + filterList(threadHandle?: string): FilterStackResult { + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const threadsKey = getThreadsKey(threadIndexes); + const actualHandle = + threadHandle ?? this._threadMap.handleForThreadIndexes(threadIndexes); + + const filters = this._filterStack.list(threadsKey); + return { + type: 'filter-stack', + threadHandle: actualHandle, + filters, + }; + } + + /** + * Temporarily push a list of sample filter specs as Redux transforms, run fn(), + * then pop them. Used to apply ephemeral (one-shot) filters to a single command. + */ + private _withEphemeralFilters( + threadIndexes: Set, + filters: SampleFilterSpec[], + fn: () => T + ): T { + if (filters.length === 0) { + return fn(); + } + const threadsKey = getThreadsKey(threadIndexes); + const stackLengthBefore = getTransformStack( + this._store.getState(), + threadsKey + ).length; + + for (const spec of filters) { + pushSpecTransforms(this._store, threadsKey, spec); + } + + try { + return fn(); + } finally { + this._store.dispatch({ + type: 'POP_TRANSFORMS_FROM_STACK', + threadsKey, + firstPoppedFilterIndex: stackLengthBefore, + }); + } + } + /** * Apply a drop-category transform for the Idle category around a computation, * then restore the transform stack to its previous state. @@ -591,6 +760,15 @@ export class ProfileQuerier { }; }); + // Collect active filter stacks + const filterStacks = this._filterStack + .activeThreadsKeys() + .map((threadsKey) => ({ + threadsKey, + threadHandle: this._threadMap.handleForKey(threadsKey), + filters: this._filterStack.list(threadsKey), + })); + return { type: 'status', selectedThreadHandle, @@ -600,6 +778,7 @@ export class ProfileQuerier { start: rootRange.start, end: rootRange.end, }, + filterStacks, }; } @@ -729,7 +908,8 @@ export class ProfileQuerier { async threadFunctions( threadHandle?: string, filterOptions?: FunctionFilterOptions, - includeIdle: boolean = false + includeIdle: boolean = false, + sampleFilters?: SampleFilterSpec[] ): Promise> { const activeOnly = !includeIdle; const threadIndexes = @@ -743,10 +923,22 @@ export class ProfileQuerier { threadHandle, filterOptions ); - const result = activeOnly - ? this._withDroppedIdle(threadIndexes, collect) - : collect(); - return { ...result, activeOnly, context: this._getContext() }; + const withIdle = activeOnly + ? () => this._withDroppedIdle(threadIndexes, collect) + : collect; + const result = + sampleFilters && sampleFilters.length > 0 + ? this._withEphemeralFilters(threadIndexes, sampleFilters, withIdle) + : withIdle(); + const activeFilters = this._filterStack.list(getThreadsKey(threadIndexes)); + return { + ...result, + activeOnly, + activeFilters: activeFilters.length > 0 ? activeFilters : undefined, + ephemeralFilters: + sampleFilters && sampleFilters.length > 0 ? sampleFilters : undefined, + context: this._getContext(), + }; } /** diff --git a/src/profile-query/thread-map.ts b/src/profile-query/thread-map.ts index 09efc233b5..7a28035468 100644 --- a/src/profile-query/thread-map.ts +++ b/src/profile-query/thread-map.ts @@ -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/. */ -import type { ThreadIndex } from 'firefox-profiler/types'; +import type { ThreadIndex, ThreadsKey } from 'firefox-profiler/types'; /** * Maps thread handles (like "t-0", "t-1") to thread indices. @@ -44,4 +44,21 @@ export class ThreadMap { const sorted = Array.from(threadIndexes).sort((a, b) => a - b); return sorted.map((idx) => this.handleForThreadIndex(idx)).join(','); } + + /** + * Convert a ThreadsKey back to a user-facing handle string (e.g. "t-0" or "t-0,t-1"). + * ThreadsKey can be a single ThreadIndex (number) or a comma-separated string of + * descending-sorted thread indexes. + */ + handleForKey(threadsKey: ThreadsKey): string { + if (typeof threadsKey === 'number') { + return this.handleForThreadIndex(threadsKey); + } + // String of comma-separated thread indexes (descending) → sort ascending for display. + const indexes = threadsKey + .split(',') + .map(Number) + .sort((a, b) => a - b); + return indexes.map((idx) => this.handleForThreadIndex(idx)).join(','); + } } diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index fcb74bbe0f..a319519fab 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -42,6 +42,42 @@ export type FunctionFilterOptions = { limit?: number; // Limit the number of functions in output }; +// ===== Sample Filter Stack ===== + +/** + * The specification for a single entry on the pq filter stack. + * Each entry corresponds to one `pq filter push` invocation. + */ +export type SampleFilterSpec = + // Phase 1: Redux transform-backed filters + | { type: 'excludes-function'; funcIndexes: number[] } + | { type: 'merge'; funcIndexes: number[] } + | { type: 'root-at'; funcIndex: number } + | { type: 'during-marker'; searchString: string } + // Phase 2: Extended filter-samples transforms + | { type: 'includes-function'; funcIndexes: number[] } + | { type: 'includes-prefix'; funcIndexes: number[] } + | { type: 'includes-suffix'; funcIndex: number } + | { type: 'outside-marker'; searchString: string }; + +export type FilterEntry = { + /** 1-based sequential index for display */ + index: number; + spec: SampleFilterSpec; + /** Human-readable description */ + description: string; + /** Number of Redux transforms this entry pushed (0 for querier-layer-only entries) */ + reduxTransformCount: number; +}; + +export type FilterStackResult = { + type: 'filter-stack'; + threadHandle: string; + filters: FilterEntry[]; + action?: 'push' | 'pop' | 'clear'; + message?: string; +}; + // ===== Session Context ===== // Context information included in all command results for persistent display @@ -87,6 +123,12 @@ export type StatusResult = { start: number; end: number; }; + /** Filter stacks for all threads that have active filters */ + filterStacks: Array<{ + threadsKey: string | number; + threadHandle: string; + filters: FilterEntry[]; + }>; }; // ===== Function Commands ===== @@ -186,6 +228,8 @@ export type ThreadSamplesResult = { friendlyThreadName: string; activeOnly?: boolean; search?: string; + activeFilters?: FilterEntry[]; + ephemeralFilters?: SampleFilterSpec[]; topFunctionsByTotal: TopFunctionInfo[]; topFunctionsBySelf: TopFunctionInfo[]; heaviestStack: { @@ -208,6 +252,8 @@ export type ThreadSamplesTopDownResult = { friendlyThreadName: string; activeOnly?: boolean; search?: string; + activeFilters?: FilterEntry[]; + ephemeralFilters?: SampleFilterSpec[]; regularCallTree: CallTreeNode; }; @@ -217,6 +263,8 @@ export type ThreadSamplesBottomUpResult = { friendlyThreadName: string; activeOnly?: boolean; search?: string; + activeFilters?: FilterEntry[]; + ephemeralFilters?: SampleFilterSpec[]; invertedCallTree: CallTreeNode | null; }; @@ -319,6 +367,8 @@ export type ThreadFunctionsResult = { threadHandle: string; friendlyThreadName: string; activeOnly?: boolean; + activeFilters?: FilterEntry[]; + ephemeralFilters?: SampleFilterSpec[]; totalFunctionCount: number; filteredFunctionCount: number; filters?: { From 88d7bb4f9acfddf4a40969916ebd5815f66a5c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Sat, 11 Apr 2026 17:03:31 +0200 Subject: [PATCH 25/63] Mention --session in the guide --- profile-query-cli/guide.txt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/profile-query-cli/guide.txt b/profile-query-cli/guide.txt index 50719fd103..af3d92aaa8 100644 --- a/profile-query-cli/guide.txt +++ b/profile-query-cli/guide.txt @@ -13,11 +13,19 @@ SESSION MODEL The daemon persists your selected thread, zoom ranges, and filter stacks across queries. + For automation or concurrent work, prefer an explicit session ID so each workflow + gets isolated state: + pq load profile.json.gz --session run-a + pq profile info --session run-a + pq thread select t-0 --session run-a + pq thread samples --session run-a + CORE WORKFLOW Step 1: Load a profile pq load profile.json.gz + pq load profile.json.gz --session run-a Recommended for automation or concurrent work Step 2: Explore the profile pq profile info Overview: processes, threads, time range, CPU activity From 6ee16e18f9453a97bd2f6d99856bdcbea67f8807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Sat, 11 Apr 2026 17:14:00 +0200 Subject: [PATCH 26/63] Initialize thread handles during the profile load --- profile-query-cli/src/test/integration/basic.test.ts | 10 ++++++++++ src/profile-query/index.ts | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/profile-query-cli/src/test/integration/basic.test.ts b/profile-query-cli/src/test/integration/basic.test.ts index 262c16d8e5..b601ec1819 100644 --- a/profile-query-cli/src/test/integration/basic.test.ts +++ b/profile-query-cli/src/test/integration/basic.test.ts @@ -60,6 +60,16 @@ describe('pq basic functionality', () => { expect(result.stdout).toContain('This profile contains'); }); + test('thread select works immediately after load', async () => { + await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const result = await pq(ctx, ['thread', 'select', 't-0']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Selected thread'); + expect(result.stdout).toContain('t-0'); + }); + test('stop cleans up session', async () => { await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); await pq(ctx, ['stop']); diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 03b1dca41c..409237ae63 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -106,6 +106,11 @@ export class ProfileQuerier { uniquePids.forEach((pid, index) => { this._processIndexMap.set(pid, index); }); + + // Seed thread handles eagerly so they are available immediately after load. + profile.threads.forEach((_, index) => { + this._threadMap.handleForThreadIndex(index); + }); } static async load(filePathOrUrl: string): Promise { From 22929f81394453a979b649ee8b55afbc2e44649d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Sat, 11 Apr 2026 17:39:34 +0200 Subject: [PATCH 27/63] Test cleanup and add some tests --- eslint.config.mjs | 2 +- .../src/test/integration/basic.test.ts | 145 ++++++++++++++++-- .../test/integration/daemon-startup.test.ts | 18 +-- .../src/test/integration/sessions.test.ts | 42 ++++- src/test/components/MenuButtons.test.tsx | 14 +- .../unit/profile-query/cpu-activity.test.ts | 53 +++++++ .../marker-utils.test.ts} | 60 +++++++- 7 files changed, 301 insertions(+), 33 deletions(-) create mode 100644 src/test/unit/profile-query/cpu-activity.test.ts rename src/test/unit/{profile-query-marker-utils.test.ts => profile-query/marker-utils.test.ts} (78%) diff --git a/eslint.config.mjs b/eslint.config.mjs index c2bbe4484c..771e893906 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -253,7 +253,7 @@ export default defineConfig( // Test files override { - files: ['src/test/**/*'], + files: ['src/test/**/*', 'profile-query-cli/src/test/**/*'], languageOptions: { globals: { ...globals.jest, diff --git a/profile-query-cli/src/test/integration/basic.test.ts b/profile-query-cli/src/test/integration/basic.test.ts index b601ec1819..1c8ea3949b 100644 --- a/profile-query-cli/src/test/integration/basic.test.ts +++ b/profile-query-cli/src/test/integration/basic.test.ts @@ -28,7 +28,7 @@ describe('pq basic functionality', () => { await cleanupTestContext(ctx); }); - test('load creates a session', async () => { + it('load creates a session', async () => { const result = await pq(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-1.json', @@ -51,7 +51,7 @@ describe('pq basic functionality', () => { expect(files).toContain('current.txt'); }); - test('profile info works after load', async () => { + it('profile info works after load', async () => { await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); const result = await pq(ctx, ['profile', 'info']); @@ -60,7 +60,7 @@ describe('pq basic functionality', () => { expect(result.stdout).toContain('This profile contains'); }); - test('thread select works immediately after load', async () => { + it('thread select works immediately after load', async () => { await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); const result = await pq(ctx, ['thread', 'select', 't-0']); @@ -70,7 +70,7 @@ describe('pq basic functionality', () => { expect(result.stdout).toContain('t-0'); }); - test('stop cleans up session', async () => { + it('stop cleans up session', async () => { await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); await pq(ctx, ['stop']); @@ -79,7 +79,7 @@ describe('pq basic functionality', () => { expect(files.filter((f) => f.endsWith('.sock'))).toHaveLength(0); }); - test('load fails for missing file', async () => { + it('load fails for missing file', async () => { const result = await pqFail(ctx, ['load', '/nonexistent/file.json']); expect(result.exitCode).not.toBe(0); @@ -87,7 +87,7 @@ describe('pq basic functionality', () => { expect(output).toContain('not found'); }); - test('profile info fails without active session', async () => { + it('profile info fails without active session', async () => { const result = await pqFail(ctx, ['profile', 'info']); expect(result.exitCode).not.toBe(0); @@ -95,7 +95,7 @@ describe('pq basic functionality', () => { expect(output).toContain('No active session'); }); - test('multiple profile info calls work (daemon stays running)', async () => { + it('multiple profile info calls work (daemon stays running)', async () => { await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); // First call @@ -108,7 +108,7 @@ describe('pq basic functionality', () => { expect(result2.stdout).toEqual(result1.stdout); }); - test('numeric zero marker filters are preserved instead of being ignored', async () => { + it('numeric zero marker filters are preserved instead of being ignored', async () => { await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); const minDurationResult = await pq(ctx, [ @@ -130,7 +130,7 @@ describe('pq basic functionality', () => { expect(maxDurationResult.stdout).toContain('"maxDuration": 0'); }); - test('numeric zero function filters are preserved instead of being ignored', async () => { + it('numeric zero function filters are preserved instead of being ignored', async () => { await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); const result = await pq(ctx, [ @@ -144,7 +144,130 @@ describe('pq basic functionality', () => { expect(result.stdout).toContain('"minSelf": 0'); }); - test('max-lines=0 is rejected instead of silently falling back to the default', async () => { + it('sticky filters are isolated per thread and reported in status', async () => { + await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + await pq(ctx, ['thread', 'select', 't-0']); + + await pq(ctx, ['filter', 'push', '--merge', 'f-1']); + + const filterListResult = await pq(ctx, ['filter', 'list', '--json']); + const filterList = JSON.parse(filterListResult.stdout) as { + type: string; + threadHandle: string; + filters: Array<{ + spec: { type: string; funcIndexes?: number[] }; + }>; + }; + + expect(filterList.type).toBe('filter-stack'); + expect(filterList.threadHandle).toBe('t-0'); + expect(filterList.filters).toHaveLength(1); + expect(filterList.filters[0].spec).toEqual({ + type: 'merge', + funcIndexes: [1], + }); + + const statusResult = await pq(ctx, ['status', '--json']); + const status = JSON.parse(statusResult.stdout) as { + type: string; + filterStacks: Array<{ + threadHandle: string; + filters: Array<{ + spec: { type: string; funcIndexes?: number[] }; + }>; + }>; + }; + + expect(status.type).toBe('status'); + expect(status.filterStacks).toHaveLength(1); + expect(status.filterStacks[0]).toEqual( + expect.objectContaining({ + threadHandle: 't-0', + filters: [ + expect.objectContaining({ + spec: { type: 'merge', funcIndexes: [1] }, + }), + ], + }) + ); + + await pq(ctx, ['thread', 'select', 't-1']); + + const otherThreadFilterListResult = await pq(ctx, [ + 'filter', + 'list', + '--json', + ]); + const otherThreadFilterList = JSON.parse( + otherThreadFilterListResult.stdout + ) as { + threadHandle: string; + filters: unknown[]; + }; + + expect(otherThreadFilterList.threadHandle).toBe('t-1'); + expect(otherThreadFilterList.filters).toHaveLength(0); + + const explicitThreadFilterListResult = await pq(ctx, [ + 'filter', + 'list', + '--thread', + 't-0', + '--json', + ]); + const explicitThreadFilterList = JSON.parse( + explicitThreadFilterListResult.stdout + ) as { + threadHandle: string; + filters: Array<{ + spec: { type: string; funcIndexes?: number[] }; + }>; + }; + + expect(explicitThreadFilterList.threadHandle).toBe('t-0'); + expect(explicitThreadFilterList.filters).toHaveLength(1); + expect(explicitThreadFilterList.filters[0].spec).toEqual({ + type: 'merge', + funcIndexes: [1], + }); + }); + + it('ephemeral sample filters do not persist into session state', async () => { + await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const samplesResult = await pq(ctx, [ + 'thread', + 'samples', + '--json', + '--merge', + 'f-1', + ]); + const samples = JSON.parse(samplesResult.stdout) as { + type: string; + ephemeralFilters?: Array<{ type: string; funcIndexes?: number[] }>; + activeFilters?: unknown[]; + }; + + expect(samples.type).toBe('thread-samples'); + expect(samples.ephemeralFilters).toEqual([ + { type: 'merge', funcIndexes: [1] }, + ]); + expect(samples.activeFilters).toBeUndefined(); + + const filterListResult = await pq(ctx, ['filter', 'list', '--json']); + const filterList = JSON.parse(filterListResult.stdout) as { + filters: unknown[]; + }; + expect(filterList.filters).toHaveLength(0); + + const statusResult = await pq(ctx, ['status', '--json']); + const status = JSON.parse(statusResult.stdout) as { + filterStacks: unknown[]; + }; + expect(status.filterStacks).toHaveLength(0); + }); + + it('max-lines=0 is rejected instead of silently falling back to the default', async () => { await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); const result = await pqFail(ctx, [ @@ -159,7 +282,7 @@ describe('pq basic functionality', () => { expect(output).toContain('--max-lines must be a positive integer'); }); - test('build hash mismatch stops the daemon before cleaning up the session', async () => { + it('build hash mismatch stops the daemon before cleaning up the session', async () => { const loadResult = await pq(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-1.json', diff --git a/profile-query-cli/src/test/integration/daemon-startup.test.ts b/profile-query-cli/src/test/integration/daemon-startup.test.ts index f5ace135eb..c879e1a239 100644 --- a/profile-query-cli/src/test/integration/daemon-startup.test.ts +++ b/profile-query-cli/src/test/integration/daemon-startup.test.ts @@ -29,7 +29,7 @@ describe('daemon startup (two-phase)', () => { await cleanupTestContext(ctx); }); - test('daemon creates socket and metadata before loading profile', async () => { + it('daemon creates socket and metadata before loading profile', async () => { const startTime = Date.now(); const result = await pq(ctx, [ @@ -62,7 +62,7 @@ describe('daemon startup (two-phase)', () => { expect(metadata.profilePath).toContain('processed-1.json'); }); - test('load returns non-zero exit code on profile load failure', async () => { + it('load returns non-zero exit code on profile load failure', async () => { // Create an invalid JSON file const invalidProfile = join(ctx.sessionDir, 'invalid.json'); const { writeFile } = await import('fs/promises'); @@ -75,7 +75,7 @@ describe('daemon startup (two-phase)', () => { expect(output).toMatch(/Profile load failed|Failed to|parse|invalid/i); }); - test('daemon startup fails fast with short timeout', async () => { + it('daemon startup fails fast with short timeout', async () => { // This test verifies Phase 1 timeout behavior // We can't easily force a daemon startup failure, but we can // verify the timeout is reasonable by checking it doesn't wait forever @@ -86,7 +86,7 @@ describe('daemon startup (two-phase)', () => { expect(result.exitCode).not.toBe(0); }); - test('load blocks until profile is fully loaded', async () => { + it('load blocks until profile is fully loaded', async () => { // Start loading await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); @@ -96,7 +96,7 @@ describe('daemon startup (two-phase)', () => { expect(result.stdout).toContain('This profile contains'); }); - test('validates session before returning (checks process + socket)', async () => { + it('validates session before returning (checks process + socket)', async () => { const result = await pq(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-1.json', @@ -110,10 +110,10 @@ describe('daemon startup (two-phase)', () => { const socketPath = getSocketPath(ctx.sessionDir, sessionId); const metadataPath = join(ctx.sessionDir, `${sessionId}.json`); - // Named pipes on Windows are not filesystem files, skip the access check there - if (process.platform !== 'win32') { - await expect(access(socketPath)).resolves.toBeUndefined(); - } + // Named pipes on Windows are not filesystem files, so treat that case as a no-op. + const socketAccessPromise = + process.platform === 'win32' ? Promise.resolve() : access(socketPath); + await expect(socketAccessPromise).resolves.toBeUndefined(); await expect(access(metadataPath)).resolves.toBeUndefined(); // Process should be running (metadata contains PID) diff --git a/profile-query-cli/src/test/integration/sessions.test.ts b/profile-query-cli/src/test/integration/sessions.test.ts index b34a793834..7c3216023f 100644 --- a/profile-query-cli/src/test/integration/sessions.test.ts +++ b/profile-query-cli/src/test/integration/sessions.test.ts @@ -7,6 +7,8 @@ * Migrated from bin/pq-test-multi bash script. */ +import { access, writeFile } from 'fs/promises'; +import { join } from 'path'; import { createTestContext, cleanupTestContext, @@ -26,7 +28,7 @@ describe('pq multiple concurrent sessions', () => { await cleanupTestContext(ctx); }); - test('can run multiple sessions with explicit IDs', async () => { + it('can run multiple sessions with explicit IDs', async () => { const session1 = 'test-session-1'; const session2 = 'test-session-2'; const session3 = 'test-session-3'; @@ -71,7 +73,7 @@ describe('pq multiple concurrent sessions', () => { await pq(ctx, ['stop', '--session', session3]); }); - test('list-sessions shows running sessions', async () => { + it('list-sessions shows running sessions', async () => { // Start two sessions await pq(ctx, [ 'load', @@ -97,7 +99,7 @@ describe('pq multiple concurrent sessions', () => { await pq(ctx, ['stop', '--all']); }); - test('stop --all stops all sessions', async () => { + it('stop --all stops all sessions', async () => { // Start multiple sessions await pq(ctx, [ 'load', @@ -120,7 +122,7 @@ describe('pq multiple concurrent sessions', () => { expect(result.stdout).toContain('Found 0 running sessions'); }); - test('reusing a live explicit session id fails without replacing the daemon', async () => { + it('reusing a live explicit session id fails without replacing the daemon', async () => { const sessionId = 'shared-session'; await pq(ctx, [ @@ -146,4 +148,36 @@ describe('pq multiple concurrent sessions', () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain('This profile contains'); }); + + it('list-sessions cleans up stale session metadata files', async () => { + const staleSessionId = 'stale-session'; + const metadataPath = join(ctx.sessionDir, `${staleSessionId}.json`); + const socketPath = join(ctx.sessionDir, `${staleSessionId}.sock`); + const currentPath = join(ctx.sessionDir, 'current.txt'); + + await writeFile(socketPath, '', 'utf-8'); + await writeFile(currentPath, staleSessionId, 'utf-8'); + await writeFile( + metadataPath, + JSON.stringify({ + id: staleSessionId, + socketPath, + logPath: join(ctx.sessionDir, `${staleSessionId}.log`), + pid: 999999, + profilePath: '/tmp/does-not-exist.json', + createdAt: '2026-04-11T00:00:00.000Z', + buildHash: 'stale-build', + }), + 'utf-8' + ); + + const result = await pq(ctx, ['list-sessions']); + + expect(result.stdout).toContain('Cleaned up 1 stale sessions.'); + expect(result.stdout).toContain('Found 0 running sessions'); + + await expect(access(metadataPath)).rejects.toThrow(); + await expect(access(socketPath)).rejects.toThrow(); + await expect(access(currentPath)).rejects.toThrow(); + }); }); diff --git a/src/test/components/MenuButtons.test.tsx b/src/test/components/MenuButtons.test.tsx index 18b0d562ca..6b709bdbf1 100644 --- a/src/test/components/MenuButtons.test.tsx +++ b/src/test/components/MenuButtons.test.tsx @@ -681,7 +681,7 @@ describe('app/MenuButtons', function () { return setupResult; } - test('does not display the delete button if the profile is public but without uploaded data', async () => { + it('does not display the delete button if the profile is public but without uploaded data', async () => { const { getMetaInfoPanel } = await setupForDeletion(); // We wait a bit using the "find" flavor of the queries because this is // reached asynchronously. @@ -690,7 +690,7 @@ describe('app/MenuButtons', function () { expect(getMetaInfoPanel()).toMatchSnapshot(); }); - test('displays the delete button if we have the uploaded data but no JWT token', async () => { + it('displays the delete button if we have the uploaded data but no JWT token', async () => { await addUploadedProfileInformation(); const { getMetaInfoPanel } = await setupForDeletion(); expect(await screen.findByText('Uploaded:')).toBeInTheDocument(); @@ -698,7 +698,7 @@ describe('app/MenuButtons', function () { expect(getMetaInfoPanel()).toMatchSnapshot(); }); - test('displays the delete button if we have the uploaded data and some JWT token', async () => { + it('displays the delete button if we have the uploaded data and some JWT token', async () => { await addUploadedProfileInformation({ jwtToken: 'FAKE_TOKEN' }); const { getMetaInfoPanel } = await setupForDeletion(); expect(await screen.findByText('Uploaded:')).toBeInTheDocument(); @@ -706,7 +706,7 @@ describe('app/MenuButtons', function () { expect(getMetaInfoPanel()).toMatchSnapshot(); }); - test('clicking on the button shows the confirmation', async () => { + it('clicking on the button shows the confirmation', async () => { await addUploadedProfileInformation({ jwtToken: 'FAKE_TOKEN' }); const { getMetaInfoPanel } = await setupForDeletion(); fireFullClick(await screen.findByText('Delete')); @@ -716,7 +716,7 @@ describe('app/MenuButtons', function () { expect(getMetaInfoPanel()).toMatchSnapshot(); }); - test('clicking on the "cancel" button will move back to the profile information', async () => { + it('clicking on the "cancel" button will move back to the profile information', async () => { await addUploadedProfileInformation({ jwtToken: 'FAKE_TOKEN' }); await setupForDeletion(); fireFullClick(await screen.findByText('Delete')); @@ -728,7 +728,7 @@ describe('app/MenuButtons', function () { expect(screen.getByText('Profile Information')).toBeInTheDocument(); }); - test('dismissing the panel will move back to the profile information when opened again', async () => { + it('dismissing the panel will move back to the profile information when opened again', async () => { await addUploadedProfileInformation({ jwtToken: 'FAKE_TOKEN' }); const { displayMetaInfoPanel, waitForPanelToBeRemoved } = await setupForDeletion(); @@ -743,7 +743,7 @@ describe('app/MenuButtons', function () { expect(screen.getByText('Profile Information')).toBeInTheDocument(); }); - test('confirming the delete should delete on the server and in the db', async () => { + it('confirming the delete should delete on the server and in the db', async () => { await addUploadedProfileInformation({ jwtToken: 'FAKE_TOKEN' }); const { getMetaInfoPanel, diff --git a/src/test/unit/profile-query/cpu-activity.test.ts b/src/test/unit/profile-query/cpu-activity.test.ts new file mode 100644 index 0000000000..6cd0a33b0d --- /dev/null +++ b/src/test/unit/profile-query/cpu-activity.test.ts @@ -0,0 +1,53 @@ +/* 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/. */ + +import { + collectSliceTree, + printSliceTree, +} from 'firefox-profiler/profile-query/cpu-activity'; +import { TimestampManager } from 'firefox-profiler/profile-query/timestamps'; + +describe('profile-query cpu activity', function () { + it('keeps interesting descendants nested under their parent in collected output', function () { + const slices = [ + { start: 0, end: 4, avg: 0.5, sum: 50, parent: null }, + { start: 1, end: 3, avg: 0.75, sum: 40, parent: 0 }, + { start: 2, end: 3, avg: 1, sum: 20, parent: 1 }, + ]; + const time = [0, 10, 20, 30, 40]; + const tsManager = new TimestampManager({ start: 0, end: 40 }); + + const result = collectSliceTree({ slices, time }, tsManager); + + expect(result).toHaveLength(3); + expect(result).toEqual([ + expect.objectContaining({ + startTime: 0, + endTime: 40, + cpuMs: 20, + depthLevel: 0, + }), + expect.objectContaining({ + startTime: 10, + endTime: 30, + cpuMs: 15, + depthLevel: 1, + }), + expect.objectContaining({ + startTime: 20, + endTime: 30, + cpuMs: 10, + depthLevel: 2, + }), + ]); + }); + + it('prints a fallback message when there are no slices', function () { + const tsManager = new TimestampManager({ start: 0, end: 10 }); + + expect(printSliceTree({ slices: [], time: [] }, tsManager)).toEqual([ + 'No significant activity.', + ]); + }); +}); diff --git a/src/test/unit/profile-query-marker-utils.test.ts b/src/test/unit/profile-query/marker-utils.test.ts similarity index 78% rename from src/test/unit/profile-query-marker-utils.test.ts rename to src/test/unit/profile-query/marker-utils.test.ts index 0752445966..1213c4c660 100644 --- a/src/test/unit/profile-query-marker-utils.test.ts +++ b/src/test/unit/profile-query/marker-utils.test.ts @@ -5,8 +5,13 @@ import { computeDurationStats, computeRateStats, + collectThreadMarkers, formatDuration, -} from '../../profile-query/formatters/marker-info'; +} from 'firefox-profiler/profile-query/formatters/marker-info'; +import { MarkerMap } from 'firefox-profiler/profile-query/marker-map'; +import { ThreadMap } from 'firefox-profiler/profile-query/thread-map'; +import { getProfileWithMarkers } from '../../fixtures/profiles/processed-profile'; +import { storeWithProfile } from '../../fixtures/stores'; import type { Marker } from 'firefox-profiler/types'; @@ -205,4 +210,57 @@ describe('marker-info utility functions', function () { expect(stats.maxGap).toBe(100); }); }); + + describe('collectThreadMarkers', function () { + it('creates nested custom groups for multi-key marker grouping', function () { + const profile = getProfileWithMarkers([ + [ + 'DOMEvent', + 0, + 2, + { eventType: 'click', latency: 1 } as Record, + ], + [ + 'DOMEvent', + 3, + 6, + { eventType: 'keydown', latency: 2 } as Record, + ], + [ + 'DOMEvent', + 7, + 9, + { eventType: 'click', latency: 3 } as Record, + ], + ]); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + const markerMap = new MarkerMap(); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { + groupBy: 'type,field:eventType', + } + ); + + expect(result.customGroups).toBeDefined(); + expect(result.customGroups).toHaveLength(1); + expect(result.customGroups?.[0].groupName).toBe('DOMEvent'); + expect(result.customGroups?.[0].count).toBe(3); + expect(result.customGroups?.[0].subGroups).toEqual([ + expect.objectContaining({ + groupName: 'click', + count: 2, + }), + expect.objectContaining({ + groupName: 'keydown', + count: 1, + }), + ]); + }); + }); }); From 55d46dbd0b024a873365ab9f4522eab745637b81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Sat, 11 Apr 2026 18:04:02 +0200 Subject: [PATCH 28/63] Run the cli tests in CI --- .github/workflows/ci.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0b60c7eec..4d85f7e43e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,21 @@ jobs: with: fail_ci_if_error: false + cli-tests: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Node.js and install dependencies + uses: ./.github/actions/setup-node-and-install + + - name: Run CLI tests + run: yarn test-cli + build-prod: runs-on: ${{ matrix.os }} strategy: From 2688251587dede2c5a459e046e8931e4c0b8309b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Sun, 12 Apr 2026 14:17:13 +0200 Subject: [PATCH 29/63] Add test:cli to the fix commands --- bin/output-fixing-commands.js | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/output-fixing-commands.js b/bin/output-fixing-commands.js index 22204d3dce..10bfb1d787 100644 --- a/bin/output-fixing-commands.js +++ b/bin/output-fixing-commands.js @@ -13,6 +13,7 @@ const fixingCommands = { 'lint-css': 'lint-fix-css', 'prettier-run': 'prettier-fix', test: 'test -u', + 'test-cli': 'test-cli -u', }; const command = process.argv.slice(2); From 05a1bad190d0c92dd9c491556f5386c666b4b952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Sun, 12 Apr 2026 14:33:20 +0200 Subject: [PATCH 30/63] Fix windows cli tests --- .../src/test/integration/basic.test.ts | 8 +++- .../src/test/integration/sessions.test.ts | 5 +- .../src/test/integration/utils.ts | 2 +- .../src/test/unit/session.test.ts | 47 ++++++++++++------- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/profile-query-cli/src/test/integration/basic.test.ts b/profile-query-cli/src/test/integration/basic.test.ts index 1c8ea3949b..a9777f519a 100644 --- a/profile-query-cli/src/test/integration/basic.test.ts +++ b/profile-query-cli/src/test/integration/basic.test.ts @@ -46,8 +46,12 @@ describe('pq basic functionality', () => { // Verify session files exist const files = await readdir(ctx.sessionDir); - expect(files).toContain(`${sessionId}.sock`); - expect(files).toContain(`${sessionId}.json`); + // Named pipes on Windows are not filesystem files, so no .sock file is created + const expectedFiles = [ + `${sessionId}.json`, + ...(process.platform !== 'win32' ? [`${sessionId}.sock`] : []), + ]; + expect(files).toEqual(expect.arrayContaining(expectedFiles)); expect(files).toContain('current.txt'); }); diff --git a/profile-query-cli/src/test/integration/sessions.test.ts b/profile-query-cli/src/test/integration/sessions.test.ts index 7c3216023f..9e95860969 100644 --- a/profile-query-cli/src/test/integration/sessions.test.ts +++ b/profile-query-cli/src/test/integration/sessions.test.ts @@ -155,7 +155,10 @@ describe('pq multiple concurrent sessions', () => { const socketPath = join(ctx.sessionDir, `${staleSessionId}.sock`); const currentPath = join(ctx.sessionDir, 'current.txt'); - await writeFile(socketPath, '', 'utf-8'); + if (process.platform !== 'win32') { + // Named pipes on Windows are not filesystem files + await writeFile(socketPath, '', 'utf-8'); + } await writeFile(currentPath, staleSessionId, 'utf-8'); await writeFile( metadataPath, diff --git a/profile-query-cli/src/test/integration/utils.ts b/profile-query-cli/src/test/integration/utils.ts index ea8f5c957f..3407b2f818 100644 --- a/profile-query-cli/src/test/integration/utils.ts +++ b/profile-query-cli/src/test/integration/utils.ts @@ -152,7 +152,7 @@ export async function runPq( timeout?: number; } ): Promise { - const result = await exec(PQ_BIN, args, { + const result = await exec(process.execPath, [PQ_BIN, ...args], { env: ctx.env, timeout: options?.timeout ?? 30000, }); diff --git a/profile-query-cli/src/test/unit/session.test.ts b/profile-query-cli/src/test/unit/session.test.ts index b2877098ea..e07d770f47 100644 --- a/profile-query-cli/src/test/unit/session.test.ts +++ b/profile-query-cli/src/test/unit/session.test.ts @@ -94,15 +94,26 @@ describe('profile-query-cli session management', function () { }); describe('path generation', function () { - it('getSocketPath returns correct path', function () { + it('getSocketPath returns correct Unix path', function () { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); const sessionId = 'test123'; const socketPath = getSocketPath(testSessionDir, sessionId); expect(socketPath).toBe(path.join(testSessionDir, 'test123.sock')); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); }); it('namespaces Windows pipe paths by session directory', function () { + const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'win32', + configurable: true, }); const firstSocketPath = getSocketPath('C:\\pq\\alpha', 'test123'); @@ -117,6 +128,11 @@ describe('profile-query-cli session management', function () { ); expect(firstSocketPath).not.toBe(secondSocketPath); expect(firstSocketPath).toBe(thirdSocketPath); + + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); }); it('generates a stable namespace from the session directory', function () { @@ -177,9 +193,6 @@ describe('profile-query-cli session management', function () { describe('current session tracking', function () { it('sets and gets current session via symlink', function () { const sessionId = 'test123'; - const socketPath = getSocketPath(testSessionDir, sessionId); - fs.writeFileSync(socketPath, ''); - setCurrentSession(testSessionDir, sessionId); const currentId = getCurrentSessionId(testSessionDir); @@ -193,14 +206,10 @@ describe('profile-query-cli session management', function () { it('replaces existing current session symlink', function () { // Create first session - const socket1 = getSocketPath(testSessionDir, 'session1'); - fs.writeFileSync(socket1, ''); setCurrentSession(testSessionDir, 'session1'); expect(getCurrentSessionId(testSessionDir)).toBe('session1'); // Create second session - const socket2 = getSocketPath(testSessionDir, 'session2'); - fs.writeFileSync(socket2, ''); setCurrentSession(testSessionDir, 'session2'); expect(getCurrentSessionId(testSessionDir)).toBe('session2'); }); @@ -208,8 +217,6 @@ describe('profile-query-cli session management', function () { it('getCurrentSocketPath resolves to correct path', function () { const sessionId = 'test123'; const socketPath = getSocketPath(testSessionDir, sessionId); - fs.writeFileSync(socketPath, ''); - setCurrentSession(testSessionDir, sessionId); const currentPath = getCurrentSocketPath(testSessionDir); @@ -259,8 +266,10 @@ describe('profile-query-cli session management', function () { const socketPath = getSocketPath(testSessionDir, sessionId); const metadataPath = getMetadataPath(testSessionDir, sessionId); - fs.writeFileSync(socketPath, ''); fs.writeFileSync(metadataPath, '{}'); + if (process.platform !== 'win32') { + fs.writeFileSync(socketPath, ''); + } cleanupSession(testSessionDir, sessionId); @@ -280,8 +289,6 @@ describe('profile-query-cli session management', function () { it('removes current session symlink if it points to this session', function () { const sessionId = 'test123'; - const socketPath = getSocketPath(testSessionDir, sessionId); - fs.writeFileSync(socketPath, ''); setCurrentSession(testSessionDir, sessionId); cleanupSession(testSessionDir, sessionId); @@ -291,8 +298,6 @@ describe('profile-query-cli session management', function () { it('does not remove current session symlink if it points to different session', function () { // Set current session to session1 - const socket1 = getSocketPath(testSessionDir, 'session1'); - fs.writeFileSync(socket1, ''); setCurrentSession(testSessionDir, 'session1'); // Clean up session2 @@ -321,12 +326,18 @@ describe('profile-query-cli session management', function () { }; saveSessionMetadata(testSessionDir, metadata); - fs.writeFileSync(metadata.socketPath, ''); expect(validateSession(testSessionDir, sessionId)).toBe(null); }); it('returns false for session with missing socket', function () { + if (process.platform === 'win32') { + // Not applicable on Windows: named pipes are self-cleaning and disappear + // automatically when the server stops, so a session can't have a live PID + // but a missing socket. validateSession skips the socket check on Windows + // for this reason. + return; + } const sessionId = 'test123'; const metadata: SessionMetadata = { id: sessionId, @@ -357,7 +368,9 @@ describe('profile-query-cli session management', function () { }; saveSessionMetadata(testSessionDir, metadata); - fs.writeFileSync(metadata.socketPath, ''); + if (process.platform !== 'win32') { + fs.writeFileSync(metadata.socketPath, ''); + } expect(validateSession(testSessionDir, sessionId)).not.toBe(null); }); From cf3fbdbae7b0c9254dd8f51ea0cbd73031b78759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 13 Apr 2026 00:15:11 +0200 Subject: [PATCH 31/63] Update readmes --- profile-query-cli/README.md | 213 +++++++++++++++++++++++------------- src/profile-query/README.md | 62 +++++++++-- 2 files changed, 184 insertions(+), 91 deletions(-) diff --git a/profile-query-cli/README.md b/profile-query-cli/README.md index 1706d757ff..0eae49720b 100644 --- a/profile-query-cli/README.md +++ b/profile-query-cli/README.md @@ -13,6 +13,8 @@ A command-line interface for querying Firefox Profiler profiles with persistent **Session storage:** `~/.pq/` (or `$PQ_SESSION_DIR` for development) +`ProfileQuerier` lives in `src/profile-query/` in the main profiler repo and is shared with the web app. The CLI daemon is just an IPC wrapper around it — query logic belongs in `src/profile-query/`, not in `daemon.ts`. + ## Commands **Note:** On machines with `pq` in PATH, you can use `pq` instead of `./dist/pq.js` for shorter commands. @@ -22,18 +24,29 @@ A command-line interface for querying Firefox Profiler profiles with persistent yarn build-profile-query-cli # Basic usage -pq load # Start daemon and load profile -pq profile info # Print profile summary -pq thread info # Print detailed thread information -pq thread select # Select a thread (e.g., t-0, t-1) -pq thread samples # Show call tree and top functions -pq function expand # Show full untruncated function name (e.g., f-1) -pq function info # Show detailed function information -pq zoom push # Push a zoom range (e.g., 2.7,3.1 or ts-g,ts-G or m-158) -pq zoom pop # Pop the most recent zoom range -pq status # Show session status (selected thread, zoom ranges) -pq stop # Stop daemon -pq list-sessions # List all running daemon sessions +pq load # Start daemon and load profile (PATH can be a file or http/https URL) +pq profile info # Print profile summary [--all] [--search ] +pq thread info # Print detailed thread information +pq thread select # Select a thread (e.g., t-0, t-1) +pq thread samples # Show hot functions list for current thread +pq thread samples-top-down # Show top-down call tree (where CPU time is spent) +pq thread samples-bottom-up # Show bottom-up call tree (what calls hot functions) +pq thread markers # List markers with aggregated statistics +pq thread functions # List all functions with CPU percentages +pq marker info # Show detailed marker information (e.g., m-1234) +pq marker stack # Show full stack trace for a marker +pq function expand # Show full untruncated function name (e.g., f-123) +pq function info # Show detailed function information +pq zoom push # Push a zoom range (e.g., 2.7,3.1 or ts-g,ts-G or m-158) +pq zoom pop # Pop the most recent zoom range +pq zoom clear # Clear all zoom ranges (return to full profile) +pq filter push # Push a sticky sample filter (see filter flags below) +pq filter pop [N] # Pop the last N filters (default: 1) +pq filter list # List active filters for current thread +pq filter clear # Remove all filters for current thread +pq status # Show session status (selected thread, zoom ranges, filters) +pq stop # Stop daemon +pq list-sessions # List all running daemon sessions # Multiple sessions pq load --session @@ -41,11 +54,44 @@ pq profile info --session pq stop --session # Thread selection -pq thread select t-93 # Select thread t-93 -pq thread samples # View samples for selected thread -pq thread info --thread t-0 # View info for specific thread without selecting +pq thread select t-93 # Select thread t-93 +pq thread samples # View samples for selected thread +pq thread info --thread t-0 # View info for specific thread without selecting ``` +Run `pq --guide` for a detailed usage guide with patterns and tips. Run `pq --help` for the full options reference. + +### Common options + +| Flag | Description | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--thread ` | Specify thread (e.g., `t-0`) | +| `--search ` | Filter results by substring | +| `--include-idle` | Include idle samples (excluded by default in samples commands) | +| `--json` | Output as JSON (for use with `jq`, etc.) | +| `--limit ` | Limit number of results shown | +| `--max-lines ` | Limit call tree nodes for `samples-top-down`/`samples-bottom-up` (default: 100) | +| `--scoring ` | Call tree scoring: `exponential-0.95`, `exponential-0.9` (default), `exponential-0.8`, `harmonic-0.1`, `harmonic-0.5`, `harmonic-1.0`, `percentage-only` | +| `--all` | Show all threads in `profile info` (overrides default top-5 limit) | +| `--session ` | Use a specific session instead of the current one | + +### Sample filter flags + +These work ephemerally on `thread samples` / `thread functions`, and as persistent filters via `filter push`. + +| Flag | Description | +| ---------------------------------- | ------------------------------------------------------------------ | +| `--excludes-function ` | Drop samples containing this function | +| `--merge ` | Remove functions from stacks (collapse them out) | +| `--root-at ` | Re-root stacks at this function | +| `--includes-function ` | Keep only samples containing any of these functions | +| `--includes-prefix ` | Keep only samples whose stack starts with this root-first sequence | +| `--includes-suffix ` | Keep only samples whose leaf frame is this function | +| `--during-marker --search ` | Keep only samples that fall during matching markers | +| `--outside-marker --search ` | Keep only samples that fall outside matching markers | + +For `filter push`, exactly one flag per push. For ephemeral use, multiple flags may be combined and applied left-to-right; the same flag may also be repeated (e.g. `--merge f-1 --merge f-2`). + ## Project Structure ``` @@ -56,6 +102,8 @@ profile-query-cli/ │ ├── daemon.ts # Daemon logic: load profile, listen on socket, handle commands │ ├── session.ts # Session file management, socket paths, validation │ ├── protocol.ts # TypeScript types for IPC messages +│ ├── formatters.ts # Plain-text formatters for structured command results +│ ├── constants.ts # Build-time constants (BUILD_HASH, etc.) │ └── test/ │ ├── unit/ # CLI unit tests │ └── integration/ # CLI integration tests @@ -113,7 +161,8 @@ On Windows, IPC uses a named pipe (`\\.\pipe\pq-`) instead of a `.so "logPath": "/Users/user/.pq/abc123xyz.log", "pid": 12345, "profilePath": "/path/to/profile.json", - "createdAt": "2025-10-31T10:00:00.000Z" + "createdAt": "2025-10-31T10:00:00.000Z", + "buildHash": "abc123" } ``` @@ -150,10 +199,12 @@ yarn test-cli **Daemon startup (client.ts):** +Two-phase startup: + 1. Spawn detached Node.js process with `--daemon` flag -2. Poll every 50ms for session readiness (max 10 seconds) -3. Check: session ID exists → metadata exists → session validates -4. Return immediately when ready +2. **Phase 1** — Poll every 50ms (max 500ms) until the session validates (metadata written, process running, socket exists) +3. **Phase 2** — Poll every 100ms (max 60s) via status messages until the profile finishes loading; fail immediately if a load error is returned +4. Return session ID when profile is ready **IPC protocol (protocol.ts):** @@ -165,19 +216,18 @@ type ClientMessage = | { type: 'status' }; type ClientCommand = - | { command: 'profile'; subcommand: 'info' | 'threads' } - | { - command: 'thread'; - subcommand: 'info' | 'select' | 'samples'; - thread?: string; - } - | { command: 'zoom'; subcommand: 'push' | 'pop'; range?: string } + | { command: 'profile'; subcommand: 'info' | 'threads'; all?: boolean; search?: string } + | { command: 'thread'; subcommand: 'info' | 'select' | 'samples' | 'samples-top-down' | 'samples-bottom-up' | 'markers' | 'functions'; thread?: string; ... } + | { command: 'marker'; subcommand: 'info' | 'select' | 'stack'; marker?: string } + | { command: 'sample'; subcommand: 'info' | 'select'; sample?: string } + | { command: 'function'; subcommand: 'info' | 'select' | 'expand'; function?: string } + | { command: 'zoom'; subcommand: 'push' | 'pop' | 'clear'; range?: string } + | { command: 'filter'; subcommand: 'push' | 'pop' | 'list' | 'clear'; thread?: string; spec?: SampleFilterSpec; count?: number } | { command: 'status' }; -// ... and more // Daemon → Client type ServerResponse = - | { type: 'success'; result: string } + | { type: 'success'; result: string | CommandResult } | { type: 'error'; error: string } | { type: 'loading' } | { type: 'ready' }; @@ -194,35 +244,14 @@ type ServerResponse = - `current` symlink uses relative path (`sessionId.sock`) - Resolved to absolute in `getCurrentSocketPath()` when needed -## Current State - -**Implemented:** +## Known Gaps -- ✅ Persistent daemon with profile loading -- ✅ Unix socket IPC (named pipes on Windows) -- ✅ Multiple concurrent sessions -- ✅ Session management (current session, explicit session IDs) -- ✅ Environment variable isolation (`PQ_SESSION_DIR`) -- ✅ Manual test scripts -- ✅ `profile info` command (shows profile name, platform, threads, CPU activity) -- ✅ `thread info` command (shows thread details, CPU activity over time) -- ✅ `thread select` command (selects a thread for subsequent queries) -- ✅ `thread samples` command (call tree, top functions, inverted tree, heaviest stack) -- ✅ `zoom push` and `zoom pop` commands (time range filtering with multiple format support, including marker handles) -- ✅ `status` command (shows selected thread and zoom range stack) -- ✅ `list-sessions` command (shows all running sessions) +These commands are parsed and routed but throw "unimplemented" in the daemon: -- ✅ `function expand` command (shows full untruncated function name) -- ✅ `function info` command (shows detailed function metadata) - -**Partially implemented (parsed and wired up but not functional):** - -- ⚠️ `profile threads` - throws "unimplemented" in daemon -- ⚠️ `thread markers` - throws "unimplemented" in daemon -- ⚠️ `thread functions` - throws "unimplemented" in daemon -- ⚠️ `marker info` and `marker select` - throws "unimplemented" in daemon -- ⚠️ `sample info` and `sample select` - throws "unimplemented" in daemon -- ⚠️ `function select` - throws "unimplemented" in daemon +- `profile threads` +- `marker select` +- `sample info`, `sample select` +- `function select` ## Build Configuration @@ -237,71 +266,97 @@ type ServerResponse = ## Adding New Commands -To add a new command, you need to modify **4 files** (client.ts doesn't need changes as it generically forwards commands): +To add a new command, you need to modify **5 files** (client.ts doesn't need changes as it generically forwards commands). The example below adds a hypothetical `pq allocation info` command. -### Step 1: Define the command type in `protocol.ts` +### Step 1: Define types in `protocol.ts` -Add your command to the `ClientCommand` type union: +Add to the `ClientCommand` union, define a result type, and add it to `CommandResult`: ```typescript -export type ClientCommand = - | { command: 'profile'; subcommand: 'info' | 'threads' } - | { command: 'marker'; subcommand: 'info'; marker?: string } // Add new command - | ... +// In ClientCommand: +| { command: 'allocation'; subcommand: 'info'; thread?: string } + +// New result type: +export type AllocationInfoResult = { + type: 'allocation-info'; + totalBytes: number; + // ... other fields +}; + +// In CommandResult: +| WithContext ``` ### Step 2: Parse CLI arguments in `index.ts` -Add a case to the command switch statement: +Add a case to the command switch, and add a corresponding case to the `formatOutput` switch: ```typescript -case 'marker': { +// In the main command switch: +case 'allocation': { const subcommand = argv._[1] ?? 'info'; - const marker = argv.marker; // Read from --marker flag - if (subcommand === 'info' || subcommand === 'select') { + if (subcommand === 'info') { const result = await sendCommand( SESSION_DIR, - { command: 'marker', subcommand, marker }, + { command: 'allocation', subcommand, thread: argv.thread }, argv.session ); - console.log(result); + console.log(formatOutput(result, argv.json || false)); } else { console.error(`Error: Unknown command ${command} ${subcommand}`); process.exit(1); } break; } + +// In the formatOutput switch: +case 'allocation-info': + return formatAllocationInfoResult(result); ``` -**Note:** client.ts doesn't need changes - it generically forwards all commands via `sendCommand()`. +**Note:** client.ts doesn't need changes — it generically forwards all commands via `sendCommand()`. ### Step 3: Handle the command in `daemon.ts` Add a case to `processCommand()`: ```typescript -case 'marker': +case 'allocation': switch (command.subcommand) { case 'info': - return this.querier!.markerInfo(command.marker); - case 'select': - return this.querier!.markerSelect(command.marker); + return this.querier!.allocationInfo(command.thread); default: throw assertExhaustiveCheck(command); } ``` -### Step 4: Implement ProfileQuerier methods in `src/profile-query/index.ts` +### Step 4: Implement the ProfileQuerier method in `src/profile-query/index.ts` + +Return a structured result type wrapped in `WithContext`, not a plain string: + +```typescript +async allocationInfo(threadHandle?: string): Promise> { + // ... + return { type: 'allocation-info', context: this._getContext(), totalBytes: ... }; +} +``` + +### Step 5: Add a formatter in `formatters.ts` ```typescript -async markerInfo(markerHandle?: string): Promise { - // Implementation - return formatMarkerInfo(this._store, this._threadMap, markerHandle); +export function formatAllocationInfoResult( + result: WithContext +): string { + const lines: string[] = [formatContextHeader(result.context)]; + lines.push(`Total allocated: ${result.totalBytes} bytes`); + return lines.join('\n'); } ``` -### Step 5: Update documentation +Then import it at the top of `index.ts`. + +### Step 6: Update documentation - Add the command to the help text in `index.ts` (the `printUsage()` function) - Add the command to the "Commands" section of this README -- Add the command to the "Current State" section as implemented +- Remove it from the "Known Gaps" section if it was previously stubbed out diff --git a/src/profile-query/README.md b/src/profile-query/README.md index 2df25f83b4..a70e7a1fe3 100644 --- a/src/profile-query/README.md +++ b/src/profile-query/README.md @@ -35,16 +35,54 @@ const threadInfo = await p1.threadInfo(); const samples = await p1.threadSamples(); ``` +All query methods return structured result objects (typed as `WithContext<...>` or a specific result type), not plain strings. The `context` field on most results includes the current selected thread and view range. + ## Available Methods +### Loading + - `static async load(filePathOrUrl: string): Promise` - Load a profile from file or URL -- `async profileInfo(): Promise` - Get profile summary (processes, threads, CPU activity) -- `async threadInfo(threadHandle?: string): Promise` - Get detailed thread information + +### Profile & Thread Info + +- `async profileInfo(showAll?: boolean, search?: string): Promise>` - Get profile summary (processes, threads, CPU activity) +- `async threadInfo(threadHandle?: string): Promise>` - Get detailed thread information - `async threadSelect(threadHandle: string): Promise` - Select a thread for subsequent queries -- `async threadSamples(threadHandle?: string): Promise` - Get call tree and top functions for a thread -- `async pushViewRange(rangeName: string): Promise` - Push a zoom range (supports timestamps, marker handles, or time values) -- `async popViewRange(): Promise` - Pop the most recent zoom range -- `async getStatus(): Promise` - Get current session status (selected thread, zoom ranges) + +### Thread Samples + +- `async threadSamples(threadHandle?: string, includeIdle?: boolean, search?: string, sampleFilters?: SampleFilterSpec[]): Promise>` - Get top functions and heaviest stack for a thread +- `async threadSamplesTopDown(threadHandle?: string, callTreeOptions?: CallTreeCollectionOptions, includeIdle?: boolean, search?: string, sampleFilters?: SampleFilterSpec[]): Promise>` - Get top-down call tree +- `async threadSamplesBottomUp(threadHandle?: string, callTreeOptions?: CallTreeCollectionOptions, includeIdle?: boolean, search?: string, sampleFilters?: SampleFilterSpec[]): Promise>` - Get bottom-up (inverted) call tree + +### Markers + +- `async threadMarkers(threadHandle?: string, filterOptions?: MarkerFilterOptions): Promise>` - List markers with aggregated statistics +- `async markerInfo(markerHandle: string): Promise>` - Get detailed information about a specific marker +- `async markerStack(markerHandle: string): Promise>` - Get the stack trace captured with a marker + +### Functions + +- `async functionExpand(functionHandle: string): Promise>` - Show the full untruncated name of a function +- `async functionInfo(functionHandle: string): Promise>` - Show detailed information about a function (library, resource, JS flags) +- `async threadFunctions(threadHandle?: string, filterOptions?: FunctionFilterOptions, includeIdle?: boolean, sampleFilters?: SampleFilterSpec[]): Promise>` - List all functions with CPU percentages + +### View Range (Zoom) + +- `async pushViewRange(rangeName: string): Promise` - Push a zoom range (supports timestamps, marker handles, seconds, milliseconds, or percentage values) +- `async popViewRange(): Promise` - Pop the most recent zoom range +- `async clearViewRange(): Promise` - Clear all zoom ranges, returning to full profile view + +### Filter Stack + +- `filterPush(spec: SampleFilterSpec, threadHandle?: string): FilterStackResult` - Push a sample filter onto the stack for the current thread +- `filterPop(count?: number, threadHandle?: string): FilterStackResult` - Pop the last `count` filters (default: 1) +- `filterClear(threadHandle?: string): FilterStackResult` - Clear all filters for the current thread +- `filterList(threadHandle?: string): FilterStackResult` - List all active filters for the current thread + +### Session Status + +- `async getStatus(): Promise` - Get current session status (selected thread, zoom range stack, active filter stacks) ## Architecture @@ -52,10 +90,10 @@ The library is built on top of the Firefox Profiler's Redux store and selectors: - **ProfileQuerier**: Main class that wraps a Redux store and provides query methods - **TimestampManager**: Manages timestamp naming for time range queries -- **ThreadMap**: Maps thread handles (e.g., "t-0", "t-1") to thread indices -- **Function handles**: Canonical handles like `f-123` refer to shared - `profile.shared.funcTable` indices and are stable across sessions for the same - processed profile data -- **Formatters**: Format query results as human-readable text +- **ThreadMap**: Maps thread handles (e.g., `t-0`, `t-1`) to thread indexes +- **MarkerMap**: Maps marker handles (e.g., `m-0`, `m-1`) to marker indexes within threads +- **FilterStack**: Manages per-thread stacks of sample filters (backed by Redux transforms) +- **Function handles**: Canonical handles like `f-123` refer to shared `profile.shared.funcTable` indices and are stable across sessions for the same processed profile data +- **Formatters**: Format query results into structured result objects -All query results are returned as formatted strings, suitable for display in a terminal or log file. +All query results are returned as typed result objects containing structured data. The CLI layer in `profile-query-cli` is responsible for formatting these into human-readable text. From c4289e1cc60bbfc6d3690fd68d94a329bc5a11f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 13 Apr 2026 00:37:35 +0200 Subject: [PATCH 32/63] Rework session commands --- profile-query-cli/README.md | 8 +- profile-query-cli/guide.txt | 4 +- profile-query-cli/src/index.ts | 101 ++++++++++++------ .../src/test/integration/sessions.test.ts | 79 ++++++++++++-- 4 files changed, 148 insertions(+), 44 deletions(-) diff --git a/profile-query-cli/README.md b/profile-query-cli/README.md index 0eae49720b..851c2c286a 100644 --- a/profile-query-cli/README.md +++ b/profile-query-cli/README.md @@ -45,13 +45,15 @@ pq filter pop [N] # Pop the last N filters (default: 1) pq filter list # List active filters for current thread pq filter clear # Remove all filters for current thread pq status # Show session status (selected thread, zoom ranges, filters) -pq stop # Stop daemon -pq list-sessions # List all running daemon sessions +pq stop # Stop current daemon +pq stop # Stop a specific session +pq stop --all # Stop all sessions +pq session list # List all running daemon sessions (* marks current) +pq session use # Switch the current session # Multiple sessions pq load --session pq profile info --session -pq stop --session # Thread selection pq thread select t-93 # Select thread t-93 diff --git a/profile-query-cli/guide.txt b/profile-query-cli/guide.txt index af3d92aaa8..80f650433a 100644 --- a/profile-query-cli/guide.txt +++ b/profile-query-cli/guide.txt @@ -291,8 +291,10 @@ TIPS SESSION MANAGEMENT - pq list-sessions List all running daemon sessions + pq session list List all running daemon sessions (* marks current) + pq session use Switch the current session pq stop Stop the current session + pq stop Stop a specific session pq stop --all Stop all sessions pq load profile.json.gz --session my-session Named session pq thread info --session my-session Query a specific session diff --git a/profile-query-cli/src/index.ts b/profile-query-cli/src/index.ts index a5cb54d270..916c575444 100644 --- a/profile-query-cli/src/index.ts +++ b/profile-query-cli/src/index.ts @@ -10,8 +10,9 @@ * pq profile info [--session ] Print profile summary * pq thread info [--thread ] Print thread information * pq thread samples [--thread ] Show thread call tree and top functions - * pq stop [--session ] [--all] Stop the daemon - * pq list-sessions List all running sessions + * pq stop [] [--all] Stop the daemon + * pq session list List all running sessions + * pq session use Switch the current session * * Build: * yarn build-profile-query-cli @@ -33,7 +34,13 @@ import guideText from '../guide.txt'; import minimist from 'minimist'; import { startDaemon } from './daemon'; import { sendCommand, startNewDaemon, stopDaemon } from './client'; -import { cleanupSession, listSessions, validateSession } from './session'; +import { + cleanupSession, + getCurrentSessionId, + listSessions, + setCurrentSession, + validateSession, +} from './session'; import type { MarkerFilterOptions, FunctionFilterOptions, @@ -114,8 +121,9 @@ Commands: filter list List active filters for current thread filter clear Remove all filters for current thread status Show session status (selected thread, zoom ranges, filters) - stop Stop the daemon session - list-sessions List all running daemon sessions + stop [] Stop the current session, a specific session, or all with --all + session list List all running daemon sessions + session use Switch the current session Options: --session Use a specific session (default: current session) @@ -198,7 +206,10 @@ Examples: pq thread functions --includes-function f-500 --limit 20 pq status pq stop - pq list-sessions + pq stop abc123 + pq stop --all + pq session list + pq session use abc123 pq thread samples-top-down --max-lines 50 pq thread samples-top-down --scoring exponential-0.8 pq thread samples-top-down --search GC @@ -986,40 +997,68 @@ async function main(): Promise { sessionIds.map((id) => stopDaemon(SESSION_DIR, id)) ); } else { - await stopDaemon(SESSION_DIR, argv.session); + // Accept session id as positional arg (pq stop ) or --session flag + const sessionId = argv._[1] ?? argv.session; + await stopDaemon(SESSION_DIR, sessionId); } break; } - case 'list-sessions': { - const sessionIds = listSessions(SESSION_DIR); - let numCleaned = 0; - const runningSessionMetadata = []; - for (const sessionId of sessionIds) { - const metadata = validateSession(SESSION_DIR, sessionId); - if (metadata === null) { - cleanupSession(SESSION_DIR, sessionId); - numCleaned++; - continue; + case 'session': { + const subcommand = argv._[1]; + if (!subcommand || subcommand === 'list') { + const sessionIds = listSessions(SESSION_DIR); + let numCleaned = 0; + const runningSessionMetadata = []; + for (const sessionId of sessionIds) { + const metadata = validateSession(SESSION_DIR, sessionId); + if (metadata === null) { + cleanupSession(SESSION_DIR, sessionId); + numCleaned++; + continue; + } + runningSessionMetadata.push(metadata); } - runningSessionMetadata.push(metadata); - } - if (numCleaned !== 0) { - console.log(`Cleaned up ${numCleaned} stale sessions.`); - console.log(); - } - runningSessionMetadata.sort( - (a, b) => - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - ); - console.log(`Found ${runningSessionMetadata.length} running sessions:`); - for (const metadata of runningSessionMetadata) { + if (numCleaned !== 0) { + console.log(`Cleaned up ${numCleaned} stale sessions.`); + console.log(); + } + runningSessionMetadata.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + const currentSessionId = getCurrentSessionId(SESSION_DIR); console.log( - `- ${metadata.id}, created at ${metadata.createdAt} [daemon pid: ${metadata.pid}]` + `Found ${runningSessionMetadata.length} running sessions:` ); + for (const metadata of runningSessionMetadata) { + const isCurrent = metadata.id === currentSessionId; + const marker = isCurrent ? '* ' : ' '; + console.log( + `${marker}${metadata.id}, created at ${metadata.createdAt} [daemon pid: ${metadata.pid}]` + ); + } + } else if (subcommand === 'use') { + const sessionId = argv._[2]; + if (!sessionId) { + console.error('Error: session use requires a session id'); + console.error('Usage: pq session use '); + process.exit(1); + } + const metadata = validateSession(SESSION_DIR, sessionId); + if (metadata === null) { + console.error( + `Error: session "${sessionId}" not found or not running` + ); + process.exit(1); + } + setCurrentSession(SESSION_DIR, sessionId); + console.log(`Switched to session ${sessionId}`); + } else { + console.error(`Error: Unknown command session ${subcommand}`); + process.exit(1); } - break; } diff --git a/profile-query-cli/src/test/integration/sessions.test.ts b/profile-query-cli/src/test/integration/sessions.test.ts index 9e95860969..bcbf82e6d6 100644 --- a/profile-query-cli/src/test/integration/sessions.test.ts +++ b/profile-query-cli/src/test/integration/sessions.test.ts @@ -67,13 +67,13 @@ describe('pq multiple concurrent sessions', () => { // Note: We don't assert that results differ, as different test profiles // might coincidentally have identical summaries. - // Stop all sessions - await pq(ctx, ['stop', '--session', session1]); + // Stop all sessions (mix of positional arg and --session flag) + await pq(ctx, ['stop', session1]); await pq(ctx, ['stop', '--session', session2]); - await pq(ctx, ['stop', '--session', session3]); + await pq(ctx, ['stop', session3]); }); - it('list-sessions shows running sessions', async () => { + it('session list shows running sessions and marks the current one', async () => { // Start two sessions await pq(ctx, [ 'load', @@ -88,17 +88,43 @@ describe('pq multiple concurrent sessions', () => { 'session-b', ]); - // List sessions - const result = await pq(ctx, ['list-sessions']); + // List sessions — session-b was loaded last, so it should be current + const result = await pq(ctx, ['session', 'list']); expect(result.stdout).toContain('Found 2 running sessions'); expect(result.stdout).toContain('session-a'); expect(result.stdout).toContain('session-b'); + expect(result.stdout).toMatch(/\* session-b/); // Clean up await pq(ctx, ['stop', '--all']); }); + it('session use switches the current session', async () => { + await pq(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + 'session-a', + ]); + await pq(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + 'session-b', + ]); + + // session-b is current; switch to session-a + const switchResult = await pq(ctx, ['session', 'use', 'session-a']); + expect(switchResult.stdout).toContain('Switched to session session-a'); + + // session list should now mark session-a as current + const listResult = await pq(ctx, ['session', 'list']); + expect(listResult.stdout).toMatch(/\* session-a/); + + await pq(ctx, ['stop', '--all']); + }); + it('stop --all stops all sessions', async () => { // Start multiple sessions await pq(ctx, [ @@ -118,10 +144,45 @@ describe('pq multiple concurrent sessions', () => { await pq(ctx, ['stop', '--all']); // Verify no sessions - const result = await pq(ctx, ['list-sessions']); + const result = await pq(ctx, ['session', 'list']); expect(result.stdout).toContain('Found 0 running sessions'); }); + it('session use with unknown id fails', async () => { + const result = await pqFail(ctx, ['session', 'use', 'does-not-exist']); + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('does-not-exist'); + }); + + it('session use causes unqualified commands to target the switched session', async () => { + await pq(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + 'session-a', + ]); + await pq(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + 'session-b', + ]); + + // Switch to session-a (session-b is current) + await pq(ctx, ['session', 'use', 'session-a']); + + // Unqualified stop should stop session-a + await pq(ctx, ['stop']); + + // session-a is gone; session-b is still running + await pqFail(ctx, ['profile', 'info', '--session', 'session-a']); + const result = await pq(ctx, ['profile', 'info', '--session', 'session-b']); + expect(result.exitCode).toBe(0); + + await pq(ctx, ['stop', '--all']); + }); + it('reusing a live explicit session id fails without replacing the daemon', async () => { const sessionId = 'shared-session'; @@ -149,7 +210,7 @@ describe('pq multiple concurrent sessions', () => { expect(result.stdout).toContain('This profile contains'); }); - it('list-sessions cleans up stale session metadata files', async () => { + it('session list cleans up stale session metadata files', async () => { const staleSessionId = 'stale-session'; const metadataPath = join(ctx.sessionDir, `${staleSessionId}.json`); const socketPath = join(ctx.sessionDir, `${staleSessionId}.sock`); @@ -174,7 +235,7 @@ describe('pq multiple concurrent sessions', () => { 'utf-8' ); - const result = await pq(ctx, ['list-sessions']); + const result = await pq(ctx, ['session', 'list']); expect(result.stdout).toContain('Cleaned up 1 stale sessions.'); expect(result.stdout).toContain('Found 0 running sessions'); From a5e066ee930c02dc4ad631beb31e0578e6e73826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 13 Apr 2026 01:32:01 +0200 Subject: [PATCH 33/63] Switch to commander from minimist This gives much better defaults for a cli, and has much better ergonomics. For example, we didn't have per-command help, and commander handles it out of the box. Also we don't have to parse all the cli arguments one by one, commander takes care of them for us. Overall it makes adding a new command and updating them easier. --- package.json | 1 + profile-query-cli/README.md | 2 +- profile-query-cli/src/commands/filter.ts | 108 ++ profile-query-cli/src/commands/function.ts | 49 + profile-query-cli/src/commands/marker.ts | 49 + profile-query-cli/src/commands/profile.ts | 44 + profile-query-cli/src/commands/session.ts | 82 ++ profile-query-cli/src/commands/shared.ts | 111 ++ profile-query-cli/src/commands/thread.ts | 362 +++++++ profile-query-cli/src/commands/zoom.ts | 56 + profile-query-cli/src/index.ts | 1124 ++------------------ profile-query-cli/src/output.ts | 79 ++ profile-query-cli/src/utils/parse.ts | 221 ++++ yarn.lock | 36 +- 14 files changed, 1304 insertions(+), 1020 deletions(-) create mode 100644 profile-query-cli/src/commands/filter.ts create mode 100644 profile-query-cli/src/commands/function.ts create mode 100644 profile-query-cli/src/commands/marker.ts create mode 100644 profile-query-cli/src/commands/profile.ts create mode 100644 profile-query-cli/src/commands/session.ts create mode 100644 profile-query-cli/src/commands/shared.ts create mode 100644 profile-query-cli/src/commands/thread.ts create mode 100644 profile-query-cli/src/commands/zoom.ts create mode 100644 profile-query-cli/src/output.ts create mode 100644 profile-query-cli/src/utils/parse.ts diff --git a/package.json b/package.json index b9ce9552ca..1793723d1b 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "long": "^5.3.2", "memoize-immutable": "^3.0.0", "memoize-one": "^6.0.0", + "commander": "14.0.3", "minimist": "^1.2.8", "mixedtuplemap": "^1.0.0", "namedtuplemap": "^1.0.0", diff --git a/profile-query-cli/README.md b/profile-query-cli/README.md index 851c2c286a..2d2cb9e0da 100644 --- a/profile-query-cli/README.md +++ b/profile-query-cli/README.md @@ -61,7 +61,7 @@ pq thread samples # View samples for selected thread pq thread info --thread t-0 # View info for specific thread without selecting ``` -Run `pq --guide` for a detailed usage guide with patterns and tips. Run `pq --help` for the full options reference. +Run `pq guide` for a detailed usage guide with patterns and tips. Run `pq --help` for the full options reference. ### Common options diff --git a/profile-query-cli/src/commands/filter.ts b/profile-query-cli/src/commands/filter.ts new file mode 100644 index 0000000000..e1eb7056a6 --- /dev/null +++ b/profile-query-cli/src/commands/filter.ts @@ -0,0 +1,108 @@ +/* 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/. */ + +/** + * `pq filter` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { parseFilterSpec } from '../utils/parse'; +import { + addGlobalOptions, + addSampleFilterOptions, + wasExplicit, +} from './shared'; + +export function registerFilterCommand( + program: Command, + sessionDir: string +): void { + const filter = program + .command('filter') + .description('Manage sticky sample filters'); + + addSampleFilterOptions( + addGlobalOptions( + filter + .command('push') + .description('Push a sticky sample filter') + .option('--thread ', 'Thread handle') + ) + ).action(async (opts) => { + const spec = parseFilterSpec({ + excludesFunction: opts.excludesFunction, + excludesAnyFunction: opts.excludesAnyFunction, + merge: opts.merge, + rootAt: opts.rootAt, + includesFunction: opts.includesFunction, + includesAnyFunction: opts.includesAnyFunction, + includesPrefix: opts.includesPrefix, + includesSuffix: opts.includesSuffix, + duringMarker: opts.duringMarker, + outsideMarker: opts.outsideMarker, + search: opts.search, + }); + const result = await sendCommand( + sessionDir, + { command: 'filter', subcommand: 'push', thread: opts.thread, spec }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + addGlobalOptions( + filter + .command('pop [count]') + .description('Pop the last N filters (default: 1)') + .option('--count ', 'Number of filters to pop') + .option('--thread ', 'Thread handle') + ).action(async (countArg: string | undefined, opts) => { + const raw = countArg ?? opts.count ?? '1'; + const count = parseInt(String(raw), 10); + if (isNaN(count) || count <= 0) { + console.error('Error: count must be a positive integer'); + process.exit(1); + } + const result = await sendCommand( + sessionDir, + { command: 'filter', subcommand: 'pop', thread: opts.thread, count }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + addGlobalOptions( + filter + .command('list', { isDefault: true }) + .description('List active filters for current thread') + .option('--thread ', 'Thread handle') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { command: 'filter', subcommand: 'list', thread: opts.thread }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + + if (!wasExplicit('filter', 'list')) { + console.log('\nOther subcommands: pq filter [options]'); + } + }); + + addGlobalOptions( + filter + .command('clear') + .description('Remove all filters for current thread') + .option('--thread ', 'Thread handle') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { command: 'filter', subcommand: 'clear', thread: opts.thread }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} diff --git a/profile-query-cli/src/commands/function.ts b/profile-query-cli/src/commands/function.ts new file mode 100644 index 0000000000..6b491275e1 --- /dev/null +++ b/profile-query-cli/src/commands/function.ts @@ -0,0 +1,49 @@ +/* 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/. */ + +/** + * `pq function` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { addGlobalOptions } from './shared'; + +export function registerFunctionCommand( + program: Command, + sessionDir: string +): void { + const fn = program.command('function').description('Function-level commands'); + + addGlobalOptions( + fn + .command('expand [handle]') + .description('Show full untruncated function name (e.g. f-123)') + .option('--function ', 'Function handle') + ).action(async (handleArg: string | undefined, opts) => { + const funcHandle = handleArg ?? opts.function; + const result = await sendCommand( + sessionDir, + { command: 'function', subcommand: 'expand', function: funcHandle }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + addGlobalOptions( + fn + .command('info [handle]') + .description('Show detailed function information (e.g. f-123)') + .option('--function ', 'Function handle') + ).action(async (handleArg: string | undefined, opts) => { + const funcHandle = handleArg ?? opts.function; + const result = await sendCommand( + sessionDir, + { command: 'function', subcommand: 'info', function: funcHandle }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} diff --git a/profile-query-cli/src/commands/marker.ts b/profile-query-cli/src/commands/marker.ts new file mode 100644 index 0000000000..6f15aea19d --- /dev/null +++ b/profile-query-cli/src/commands/marker.ts @@ -0,0 +1,49 @@ +/* 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/. */ + +/** + * `pq marker` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { addGlobalOptions } from './shared'; + +export function registerMarkerCommand( + program: Command, + sessionDir: string +): void { + const marker = program.command('marker').description('Marker-level commands'); + + addGlobalOptions( + marker + .command('info [handle]') + .description('Show detailed marker information (e.g. m-1234)') + .option('--marker ', 'Marker handle') + ).action(async (handleArg: string | undefined, opts) => { + const markerHandle = handleArg ?? opts.marker; + const result = await sendCommand( + sessionDir, + { command: 'marker', subcommand: 'info', marker: markerHandle }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + addGlobalOptions( + marker + .command('stack [handle]') + .description('Show full stack trace for a marker (e.g. m-1234)') + .option('--marker ', 'Marker handle') + ).action(async (handleArg: string | undefined, opts) => { + const markerHandle = handleArg ?? opts.marker; + const result = await sendCommand( + sessionDir, + { command: 'marker', subcommand: 'stack', marker: markerHandle }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} diff --git a/profile-query-cli/src/commands/profile.ts b/profile-query-cli/src/commands/profile.ts new file mode 100644 index 0000000000..4c1e02b611 --- /dev/null +++ b/profile-query-cli/src/commands/profile.ts @@ -0,0 +1,44 @@ +/* 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/. */ + +/** + * `pq profile` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { addGlobalOptions } from './shared'; + +export function registerProfileCommand( + program: Command, + sessionDir: string +): void { + const profile = program + .command('profile') + .description('Profile-level commands'); + + addGlobalOptions( + profile + .command('info') + .description('Print profile summary (processes, threads, CPU activity)') + .option( + '--all', + 'Show all processes and threads (overrides default top-5 limit)' + ) + .option('--search ', 'Filter by substring') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { + command: 'profile', + subcommand: 'info', + all: opts.all, + search: opts.search, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} diff --git a/profile-query-cli/src/commands/session.ts b/profile-query-cli/src/commands/session.ts new file mode 100644 index 0000000000..2618666e1b --- /dev/null +++ b/profile-query-cli/src/commands/session.ts @@ -0,0 +1,82 @@ +/* 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/. */ + +/** + * `pq session` command. + */ + +import type { Command } from 'commander'; +import { wasExplicit } from './shared'; +import { + cleanupSession, + getCurrentSessionId, + listSessions, + setCurrentSession, + validateSession, +} from '../session'; + +export function registerSessionCommand( + program: Command, + sessionDir: string +): void { + const session = program + .command('session') + .description('Manage daemon sessions'); + + session + .command('list', { isDefault: true }) + .description('List all running daemon sessions') + .action(() => { + const sessionIds = listSessions(sessionDir); + let numCleaned = 0; + const runningSessionMetadata = []; + + for (const sessionId of sessionIds) { + const metadata = validateSession(sessionDir, sessionId); + if (metadata === null) { + cleanupSession(sessionDir, sessionId); + numCleaned++; + continue; + } + runningSessionMetadata.push(metadata); + } + + if (numCleaned !== 0) { + console.log(`Cleaned up ${numCleaned} stale sessions.`); + console.log(); + } + + runningSessionMetadata.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + + const currentSessionId = getCurrentSessionId(sessionDir); + console.log(`Found ${runningSessionMetadata.length} running sessions:`); + for (const metadata of runningSessionMetadata) { + const isCurrent = metadata.id === currentSessionId; + const marker = isCurrent ? '* ' : ' '; + console.log( + `${marker}${metadata.id}, created at ${metadata.createdAt} [daemon pid: ${metadata.pid}]` + ); + } + + if (!wasExplicit('session', 'list')) { + console.log('\nOther subcommands: pq session use '); + } + }); + + session + .command('use ') + .description('Switch the current session') + .action((sessionId: string) => { + const metadata = validateSession(sessionDir, sessionId); + if (metadata === null) { + console.error(`Error: session "${sessionId}" not found or not running`); + process.exit(1); + } + setCurrentSession(sessionDir, sessionId); + console.log(`Switched to session ${sessionId}`); + }); +} diff --git a/profile-query-cli/src/commands/shared.ts b/profile-query-cli/src/commands/shared.ts new file mode 100644 index 0000000000..13ec1487f4 --- /dev/null +++ b/profile-query-cli/src/commands/shared.ts @@ -0,0 +1,111 @@ +/* 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/. */ + +/** + * Shared option helpers for pq CLI commands. + */ + +import type { Command } from 'commander'; +import { Option } from 'commander'; +import { collectStrings } from '../utils/parse'; + +/** + * Returns true if the given subcommand was explicitly typed by the user. + * Used to decide whether to print a "other subcommands" hint after a default action. + * + * e.g. `pq session` → wasExplicit('session', 'list') === false + * `pq session list` → wasExplicit('session', 'list') === true + */ +export function wasExplicit(parent: string, subcommand: string): boolean { + const args = process.argv; + const idx = args.lastIndexOf(parent); + return idx !== -1 && args[idx + 1] === subcommand; +} + +/** + * Add --session and --json options to a command. + */ +export function addGlobalOptions(cmd: Command): Command { + return cmd + .option( + '--session ', + 'Use a specific session (default: current session)' + ) + .option('--json', 'Output results as JSON'); +} + +/** + * Add all ephemeral sample filter options to a command. + * Used by `thread samples`, `thread samples-top-down`, `thread samples-bottom-up`, + * `thread functions`, and `filter push`. + */ +export function addSampleFilterOptions(cmd: Command): Command { + return cmd + .addOption( + new Option( + '--excludes-function ', + 'Drop samples containing this function' + ) + .argParser(collectStrings) + .default([]) + ) + .addOption( + new Option( + '--excludes-any-function ', + 'Drop samples containing any of these functions (OR)' + ) + .argParser(collectStrings) + .default([]) + ) + .addOption( + new Option('--merge ', 'Merge (remove) functions from stacks') + .argParser(collectStrings) + .default([]) + ) + .addOption( + new Option('--root-at ', 'Re-root stacks at this function') + .argParser(collectStrings) + .default([]) + ) + .addOption( + new Option( + '--includes-function ', + 'Keep only samples whose stack contains any of these functions' + ) + .argParser(collectStrings) + .default([]) + ) + .addOption( + new Option( + '--includes-any-function ', + 'Alias for --includes-function (explicit OR form)' + ) + .argParser(collectStrings) + .default([]) + ) + .addOption( + new Option( + '--includes-prefix ', + 'Keep only samples whose stack starts with this root-first sequence' + ) + .argParser(collectStrings) + .default([]) + ) + .addOption( + new Option( + '--includes-suffix ', + 'Keep only samples whose leaf frame is this function' + ) + .argParser(collectStrings) + .default([]) + ) + .option( + '--during-marker', + 'Keep only samples during matching markers (requires --search)' + ) + .option( + '--outside-marker', + 'Keep only samples outside matching markers (requires --search)' + ); +} diff --git a/profile-query-cli/src/commands/thread.ts b/profile-query-cli/src/commands/thread.ts new file mode 100644 index 0000000000..3ed9dd48ed --- /dev/null +++ b/profile-query-cli/src/commands/thread.ts @@ -0,0 +1,362 @@ +/* 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/. */ + +/** + * `pq thread` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { parseEphemeralFilters } from '../utils/parse'; +import { addGlobalOptions, addSampleFilterOptions } from './shared'; +import type { + CallTreeScoringStrategy, + MarkerFilterOptions, + FunctionFilterOptions, +} from '../protocol'; + +const VALID_SCORING_STRATEGIES: CallTreeScoringStrategy[] = [ + 'exponential-0.95', + + 'exponential-0.9', + 'exponential-0.8', + 'harmonic-0.1', + 'harmonic-0.5', + 'harmonic-1.0', + 'percentage-only', +]; + +function addSamplesOptions(cmd: Command): Command { + return addSampleFilterOptions( + addGlobalOptions(cmd) + .option('--thread ', 'Thread handle (e.g. t-0)') + .option('--include-idle', 'Include idle samples in percentages') + .option('--search ', 'Filter by substring') + .option('--limit ', 'Limit the number of results shown') + ); +} + +function addCallTreeOptions(cmd: Command): Command { + return addSamplesOptions(cmd) + .option('--max-lines ', 'Maximum nodes in call tree (default: 100)') + .option( + '--scoring ', + `Call tree scoring strategy: ${VALID_SCORING_STRATEGIES.join(', ')}` + ); +} + +export function registerThreadCommand( + program: Command, + sessionDir: string +): void { + const thread = program.command('thread').description('Thread-level commands'); + + // thread info + addGlobalOptions( + thread + .command('info') + .description('Print detailed thread information') + .option('--thread ', 'Thread handle (e.g. t-0)') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { command: 'thread', subcommand: 'info', thread: opts.thread }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + // thread select + addGlobalOptions( + thread + .command('select [handle]') + .description('Select a thread (e.g. t-0, t-1)') + .option('--thread ', 'Thread handle') + ).action(async (handleArg: string | undefined, opts) => { + const threadHandle = handleArg ?? opts.thread; + const result = await sendCommand( + sessionDir, + { command: 'thread', subcommand: 'select', thread: threadHandle }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + // thread samples + addSamplesOptions( + thread + .command('samples') + .description('Show hot functions list for a thread') + ).action(async (opts) => { + const sampleFilters = parseEphemeralFilters(opts); + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'samples', + thread: opts.thread, + includeIdle: opts.includeIdle || undefined, + search: opts.search, + sampleFilters: sampleFilters.length ? sampleFilters : undefined, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + // thread samples-top-down + addCallTreeOptions( + thread + .command('samples-top-down') + .description('Show top-down call tree (where CPU time is spent)') + ).action(async (opts) => { + const sampleFilters = parseEphemeralFilters(opts); + + let callTreeOptions = undefined; + if (opts.maxLines !== undefined || opts.scoring !== undefined) { + callTreeOptions = {} as { + maxNodes?: number; + scoringStrategy?: CallTreeScoringStrategy; + }; + if (opts.maxLines !== undefined) { + const maxLines = parseInt(String(opts.maxLines), 10); + if (isNaN(maxLines) || maxLines <= 0) { + console.error('Error: --max-lines must be a positive integer'); + process.exit(1); + } + callTreeOptions.maxNodes = maxLines; + } + if (opts.scoring !== undefined) { + if (!VALID_SCORING_STRATEGIES.includes(opts.scoring)) { + console.error( + `Error: --scoring must be one of: ${VALID_SCORING_STRATEGIES.join(', ')}` + ); + process.exit(1); + } + callTreeOptions.scoringStrategy = + opts.scoring as CallTreeScoringStrategy; + } + } + + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'samples-top-down', + thread: opts.thread, + includeIdle: opts.includeIdle || undefined, + search: opts.search, + callTreeOptions, + sampleFilters: sampleFilters.length ? sampleFilters : undefined, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + // thread samples-bottom-up + addCallTreeOptions( + thread + .command('samples-bottom-up') + .description('Show bottom-up call tree (what calls hot functions)') + ).action(async (opts) => { + const sampleFilters = parseEphemeralFilters(opts); + + let callTreeOptions = undefined; + if (opts.maxLines !== undefined || opts.scoring !== undefined) { + callTreeOptions = {} as { + maxNodes?: number; + scoringStrategy?: CallTreeScoringStrategy; + }; + if (opts.maxLines !== undefined) { + const maxLines = parseInt(String(opts.maxLines), 10); + if (isNaN(maxLines) || maxLines <= 0) { + console.error('Error: --max-lines must be a positive integer'); + process.exit(1); + } + callTreeOptions.maxNodes = maxLines; + } + if (opts.scoring !== undefined) { + if (!VALID_SCORING_STRATEGIES.includes(opts.scoring)) { + console.error( + `Error: --scoring must be one of: ${VALID_SCORING_STRATEGIES.join(', ')}` + ); + process.exit(1); + } + callTreeOptions.scoringStrategy = + opts.scoring as CallTreeScoringStrategy; + } + } + + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'samples-bottom-up', + thread: opts.thread, + includeIdle: opts.includeIdle || undefined, + search: opts.search, + callTreeOptions, + sampleFilters: sampleFilters.length ? sampleFilters : undefined, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + // thread markers + addGlobalOptions( + thread + .command('markers') + .description('List markers with aggregated statistics') + .option('--thread ', 'Thread handle (e.g. t-0)') + .option('--search ', 'Filter by substring') + .option( + '--category ', + 'Filter by category name (case-insensitive substring match)' + ) + .option( + '--min-duration ', + 'Filter by minimum duration in milliseconds' + ) + .option( + '--max-duration ', + 'Filter by maximum duration in milliseconds' + ) + .option('--has-stack', 'Show only markers with stack traces') + .option('--limit ', 'Limit the number of results shown') + .option( + '--group-by ', + 'Group by custom keys (e.g. "type,name" or "type,field:eventType")' + ) + .option( + '--auto-group', + 'Automatically determine grouping based on field variance' + ) + ).action(async (opts) => { + let markerFilters: MarkerFilterOptions | undefined; + + if ( + opts.search !== undefined || + opts.minDuration !== undefined || + opts.maxDuration !== undefined || + opts.category !== undefined || + opts.hasStack || + opts.limit !== undefined || + opts.groupBy !== undefined || + opts.autoGroup + ) { + markerFilters = {}; + if (opts.search !== undefined) markerFilters.searchString = opts.search; + if (opts.category !== undefined) markerFilters.category = opts.category; + if (opts.hasStack) markerFilters.hasStack = true; + if (opts.autoGroup) markerFilters.autoGroup = true; + if (opts.groupBy !== undefined) markerFilters.groupBy = opts.groupBy; + + if (opts.minDuration !== undefined) { + const minDuration = parseFloat(opts.minDuration); + if (isNaN(minDuration) || minDuration < 0) { + console.error( + 'Error: --min-duration must be a positive number (in milliseconds)' + ); + process.exit(1); + } + markerFilters.minDuration = minDuration; + } + if (opts.maxDuration !== undefined) { + const maxDuration = parseFloat(opts.maxDuration); + if (isNaN(maxDuration) || maxDuration < 0) { + console.error( + 'Error: --max-duration must be a positive number (in milliseconds)' + ); + process.exit(1); + } + markerFilters.maxDuration = maxDuration; + } + if (opts.limit !== undefined) { + const limit = parseInt(opts.limit, 10); + if (isNaN(limit) || limit <= 0) { + console.error('Error: --limit must be a positive integer'); + process.exit(1); + } + markerFilters.limit = limit; + } + } + + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'markers', + thread: opts.thread, + markerFilters, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + // thread functions + addSampleFilterOptions( + addGlobalOptions( + thread + .command('functions') + .description('List all functions with CPU percentages') + .option('--thread ', 'Thread handle (e.g. t-0)') + .option('--search ', 'Filter by substring') + .option( + '--min-self ', + 'Filter by minimum self time percentage' + ) + .option('--limit ', 'Limit the number of results shown') + .option('--include-idle', 'Include idle samples in percentages') + ) + ).action(async (opts) => { + let functionFilters: FunctionFilterOptions | undefined; + + if ( + opts.search !== undefined || + opts.minSelf !== undefined || + opts.limit !== undefined + ) { + functionFilters = {}; + if (opts.search !== undefined) functionFilters.searchString = opts.search; + if (opts.minSelf !== undefined) { + const minSelf = parseFloat(opts.minSelf); + if (isNaN(minSelf) || minSelf < 0 || minSelf > 100) { + console.error( + 'Error: --min-self must be a number between 0 and 100 (percentage)' + ); + process.exit(1); + } + functionFilters.minSelf = minSelf; + } + if (opts.limit !== undefined) { + const limit = parseInt(opts.limit, 10); + if (isNaN(limit) || limit <= 0) { + console.error('Error: --limit must be a positive integer'); + process.exit(1); + } + functionFilters.limit = limit; + } + } + + const sampleFilters = parseEphemeralFilters(opts); + + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'functions', + thread: opts.thread, + includeIdle: opts.includeIdle || undefined, + functionFilters, + sampleFilters: sampleFilters.length ? sampleFilters : undefined, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} diff --git a/profile-query-cli/src/commands/zoom.ts b/profile-query-cli/src/commands/zoom.ts new file mode 100644 index 0000000000..157b67a4f5 --- /dev/null +++ b/profile-query-cli/src/commands/zoom.ts @@ -0,0 +1,56 @@ +/* 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/. */ + +/** + * `pq zoom` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { addGlobalOptions } from './shared'; + +export function registerZoomCommand( + program: Command, + sessionDir: string +): void { + const zoom = program.command('zoom').description('Manage zoom ranges'); + + addGlobalOptions( + zoom + .command('push ') + .description('Push a zoom range (e.g. 2.7,3.1 or m-158)') + ).action(async (range: string, opts) => { + const result = await sendCommand( + sessionDir, + { command: 'zoom', subcommand: 'push', range }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + addGlobalOptions( + zoom.command('pop').description('Pop the most recent zoom range') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { command: 'zoom', subcommand: 'pop' }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + addGlobalOptions( + zoom + .command('clear') + .description('Clear all zoom ranges (return to full profile)') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { command: 'zoom', subcommand: 'clear' }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} diff --git a/profile-query-cli/src/index.ts b/profile-query-cli/src/index.ts index 916c575444..6cf294b462 100644 --- a/profile-query-cli/src/index.ts +++ b/profile-query-cli/src/index.ts @@ -20,1054 +20,146 @@ * Run: * pq (if pq is in PATH) * ./profile-query-cli/dist/pq.js (direct invocation) - * - * Helper scripts: - * ./bin/pq-test Quick smoke test - * ./bin/pq-test-multi Test multiple concurrent sessions - * ./bin/pq-status Show session status - * ./bin/pq-clean Clean up sessions (--logs to also remove logs) */ import * as path from 'path'; import * as os from 'os'; +import { Command } from 'commander'; import guideText from '../guide.txt'; -import minimist from 'minimist'; import { startDaemon } from './daemon'; -import { sendCommand, startNewDaemon, stopDaemon } from './client'; -import { - cleanupSession, - getCurrentSessionId, - listSessions, - setCurrentSession, - validateSession, -} from './session'; -import type { - MarkerFilterOptions, - FunctionFilterOptions, - SampleFilterSpec, - CommandResult, - CallTreeCollectionOptions, - CallTreeScoringStrategy, -} from './protocol'; -import { - formatStatusResult, - formatFunctionExpandResult, - formatFunctionInfoResult, - formatViewRangeResult, - formatFilterStackResult, - formatThreadInfoResult, - formatMarkerStackResult, - formatMarkerInfoResult, - formatProfileInfoResult, - formatThreadSamplesResult, - formatThreadSamplesTopDownResult, - formatThreadSamplesBottomUpResult, - formatThreadMarkersResult, - formatThreadFunctionsResult, -} from './formatters'; +import { startNewDaemon, stopDaemon, sendCommand } from './client'; +import { listSessions } from './session'; +import { formatOutput } from './output'; +import { addGlobalOptions } from './commands/shared'; +import { registerProfileCommand } from './commands/profile'; +import { registerThreadCommand } from './commands/thread'; +import { registerMarkerCommand } from './commands/marker'; +import { registerFunctionCommand } from './commands/function'; +import { registerZoomCommand } from './commands/zoom'; +import { registerFilterCommand } from './commands/filter'; +import { registerSessionCommand } from './commands/session'; // Read session directory from environment (only place this is read) const SESSION_DIR = process.env.PQ_SESSION_DIR || path.join(os.homedir(), '.pq'); -interface Args { - _: string[]; - session?: string; - daemon?: boolean; - help?: boolean; - h?: boolean; - guide?: boolean; - json?: boolean; - 'max-lines'?: number; - scoring?: string; - // filter command flags (value-taking) - 'excludes-function'?: string; - 'excludes-any-function'?: string; - merge?: string; - 'root-at'?: string; - 'includes-function'?: string; - 'includes-any-function'?: string; - 'includes-prefix'?: string; - 'includes-suffix'?: string; - count?: string; - // filter command presence flags - 'during-marker'?: boolean; - 'outside-marker'?: boolean; - search?: string; -} - -function printUsage(): void { - console.log(`Usage: pq [options] - -Commands: - load Load a profile and start a daemon session - profile info Print profile summary (processes, threads, CPU activity) [--all] [--search ] - thread info Print detailed thread information - thread select Select a thread (e.g., t-0, t-1) - thread samples Show hot functions list for a thread - thread samples-top-down Show top-down call tree (where CPU time is spent) - thread samples-bottom-up Show bottom-up call tree (what calls hot functions) - thread markers List markers with aggregated statistics - thread functions List all functions with CPU percentages - marker info Show detailed marker information (e.g., m-1234) - marker stack Show full stack trace for a marker (e.g., m-1234) - function expand Show full untruncated function name (e.g., f-123) - function info Show detailed function information - zoom push Push a zoom range (e.g., 2.7,3.1 or ts-g,ts-G or m-158) - zoom pop Pop the most recent zoom range - zoom clear Clear all zoom ranges (return to full profile) - filter push Push a sticky sample filter (see filter flags below) - filter pop [N] Pop the last N filters (default: 1) - filter list List active filters for current thread - filter clear Remove all filters for current thread - status Show session status (selected thread, zoom ranges, filters) - stop [] Stop the current session, a specific session, or all with --all - session list List all running daemon sessions - session use Switch the current session - -Options: - --session Use a specific session (default: current session) - --thread Specify thread by handle (e.g., t-0, t-1) - --marker Specify marker by handle (e.g., m-1, m-2) - --function Specify function by handle (e.g., f-123, f-456) - --search Search/filter by substring (markers, functions, and samples call trees) - --include-idle Include idle samples in percentages (samples commands exclude idle by default) - --category Filter markers by category name (case-insensitive substring match) - --min-duration Filter markers by minimum duration in milliseconds - --max-duration Filter markers by maximum duration in milliseconds - --min-self Filter functions by minimum self time percentage (for 'thread functions') - --all Show all processes and threads (for 'profile info', overrides default top-5 limit) - --has-stack Filter to show only markers with stack traces - --limit Limit the number of results shown - --group-by Group markers by custom keys (e.g., "type,name" or "type,field:eventType") - --auto-group Automatically determine grouping based on field variance - --max-lines Maximum nodes in call tree (for 'samples-top-down'/'samples-bottom-up', default: 100) - --scoring Call tree scoring: exponential-0.95, exponential-0.9 (default), exponential-0.8, - harmonic-0.1, harmonic-0.5, harmonic-1.0, percentage-only - --json Output results as JSON (for use with jq, etc.) - --guide Show detailed usage guide (commands, patterns, tips) - --help, -h Show this help message - -Sample filter flags (work on 'thread samples/functions' ephemerally AND with 'filter push' for sticky use): - --excludes-function Drop samples containing this function - --excludes-any-function Drop samples containing any of these functions (OR) - --merge Merge (remove) functions from stacks - --root-at Re-root stacks at this function - --includes-function Keep only samples whose stack contains any of these functions - --includes-any-function Alias for --includes-function (explicit OR form) - --includes-prefix Keep only samples whose stack starts with this root-first sequence - --includes-suffix Keep only samples whose leaf frame is this function - --during-marker --search Keep only samples during matching markers - --outside-marker --search Keep only samples outside matching markers +async function main(): Promise { + const rawArgs = process.argv.slice(2); - For 'filter push': exactly one filter flag per push. - For 'thread samples/functions': multiple flags may be combined; applied in left-to-right order. - The same flag may also be repeated (e.g. --merge f-1 --merge f-2) to apply it multiple times. + // Daemon escape hatch: spawned internally by startNewDaemon(), never shown in --help + if (rawArgs.includes('--daemon')) { + const daemonIdx = rawArgs.indexOf('--daemon'); + const profilePath = rawArgs.find( + (a, i) => i > daemonIdx && !a.startsWith('-') + ); + const sessionIdx = rawArgs.indexOf('--session'); + const sessionId = sessionIdx !== -1 ? rawArgs[sessionIdx + 1] : undefined; + if (!profilePath) { + console.error('Error: Profile path required for daemon mode'); + process.exit(1); + } + await startDaemon(SESSION_DIR, profilePath, sessionId); + return; + } + const program = new Command(); + program + .name('pq') + .description('Profile Querier — query Firefox profiles from the terminal') + .helpOption('-h, --help', 'Show help') + .addHelpCommand('help [command]', 'Show help for a command') + .addHelpText( + 'after', + ` Examples: pq load profile.json.gz pq profile info - pq thread info --thread t-0 + pq thread info pq thread samples - pq thread markers - pq thread functions - pq thread functions --search Present - pq thread functions --min-self 1 - pq thread functions --limit 50 - pq thread markers --search DOMEvent - pq thread markers --category Graphics - pq thread markers --min-duration 10 - pq thread markers --max-duration 100 - pq thread markers --has-stack - pq thread markers --limit 1000 - pq thread markers --group-by type,name - pq thread markers --group-by type,field:eventType - pq thread markers --auto-group - pq marker info m-1234 - pq marker stack m-1234 - pq function expand f-123 - pq function info f-123 + pq thread functions --search GC --min-self 1 + pq thread markers --search DOMEvent --category Graphics pq zoom push 2.7,3.1 - pq zoom push m-158 - pq zoom pop - pq zoom clear pq filter push --excludes-function f-184 - pq filter push --merge f-142,f-143 - pq filter push --includes-function f-500 - pq filter push --during-marker --search Paint - pq filter push --outside-marker --search GC - pq filter pop - pq filter pop 3 - pq filter list - pq filter clear - pq thread samples --excludes-function f-184 - pq thread samples --merge f-1 --merge f-2 --root-at f-500 - pq thread samples --root-at f-500 --limit 30 - pq thread functions --includes-function f-500 --limit 20 pq status - pq stop - pq stop abc123 - pq stop --all - pq session list - pq session use abc123 - pq thread samples-top-down --max-lines 50 - pq thread samples-top-down --scoring exponential-0.8 - pq thread samples-top-down --search GC - pq thread samples-bottom-up --max-lines 200 --scoring harmonic-1.0 - pq thread samples --include-idle -`); -} - -function printGuide(): void { - console.log(guideText); -} - -/** - * Format command result for output. - * If json flag is set, output JSON. Otherwise output as string. - */ -function formatOutput( - result: string | CommandResult, - jsonFlag: boolean -): string { - if (jsonFlag) { - if (typeof result === 'string') { - // Legacy string result - wrap in a simple JSON structure - return JSON.stringify({ type: 'text', result }, null, 2); - } - // Structured result - output as JSON - return JSON.stringify(result, null, 2); - } - - // Plain text output - if (typeof result === 'string') { - return result; - } - - // Format structured results as plain text - switch (result.type) { - case 'status': - return formatStatusResult(result); - case 'filter-stack': - return formatFilterStackResult(result); - case 'function-expand': - return formatFunctionExpandResult(result); - case 'function-info': - return formatFunctionInfoResult(result); - case 'view-range': - return formatViewRangeResult(result); - case 'thread-info': - return formatThreadInfoResult(result); - case 'marker-stack': - return formatMarkerStackResult(result); - case 'marker-info': - return formatMarkerInfoResult(result); - case 'profile-info': - return formatProfileInfoResult(result); - case 'thread-samples': - return formatThreadSamplesResult(result); - case 'thread-samples-top-down': - return formatThreadSamplesTopDownResult(result); - case 'thread-samples-bottom-up': - return formatThreadSamplesBottomUpResult(result); - case 'thread-markers': - return formatThreadMarkersResult(result); - case 'thread-functions': - return formatThreadFunctionsResult(result); - default: - // For types without formatters yet, fall back to JSON - return JSON.stringify(result, null, 2); - } -} - -/** - * Parse zero or more ephemeral SampleFilterSpecs from CLI arguments. - * Used for `pq thread samples/functions` etc. to apply filters for one invocation. - * Multiple flags are collected in order; each produces one spec. The same flag may be - * repeated (e.g. --merge f-1 --merge f-2) to apply it multiple times. - */ -function parseEphemeralFilters(argv: Args): SampleFilterSpec[] { - function parseFuncList(value: string): number[] { - return value.split(',').map((s) => { - const m = /^f-(\d+)$/.exec(s.trim()); - if (!m) { - console.error( - `Error: invalid function handle "${s.trim()}" (expected f-)` - ); - process.exit(1); - } - return parseInt(m[1], 10); - }); - } - - // Normalize a flag value to an array to support repeated flags (e.g. --merge x --merge y). - function toArray(value: unknown): string[] { - if (value === undefined) return []; - return Array.isArray(value) ? value : [value as string]; - } - - const specs: SampleFilterSpec[] = []; - - for (const v of toArray(argv['excludes-function'])) { - specs.push({ type: 'excludes-function', funcIndexes: parseFuncList(v) }); - } - for (const v of toArray(argv['excludes-any-function'])) { - specs.push({ type: 'excludes-function', funcIndexes: parseFuncList(v) }); - } - for (const v of toArray(argv.merge)) { - specs.push({ type: 'merge', funcIndexes: parseFuncList(v) }); - } - for (const v of toArray(argv['root-at'])) { - const indexes = parseFuncList(v); - if (indexes.length !== 1) { - console.error('Error: --root-at takes exactly one function handle'); - process.exit(1); - } - specs.push({ type: 'root-at', funcIndex: indexes[0] }); - } - for (const v of toArray(argv['includes-function'])) { - specs.push({ type: 'includes-function', funcIndexes: parseFuncList(v) }); - } - for (const v of toArray(argv['includes-any-function'])) { - specs.push({ type: 'includes-function', funcIndexes: parseFuncList(v) }); - } - for (const v of toArray(argv['includes-prefix'])) { - specs.push({ type: 'includes-prefix', funcIndexes: parseFuncList(v) }); - } - for (const v of toArray(argv['includes-suffix'])) { - const indexes = parseFuncList(v); - if (indexes.length !== 1) { - console.error( - 'Error: --includes-suffix takes exactly one function handle' - ); - process.exit(1); - } - specs.push({ type: 'includes-suffix', funcIndex: indexes[0] }); - } - if (argv['during-marker'] === true) { - if (!argv.search) { - console.error('Error: --during-marker requires --search '); - process.exit(1); - } - specs.push({ type: 'during-marker', searchString: argv.search }); - } - if (argv['outside-marker'] === true) { - if (!argv.search) { - console.error('Error: --outside-marker requires --search '); - process.exit(1); - } - specs.push({ type: 'outside-marker', searchString: argv.search }); - } - - return specs; -} - -/** - * Parse a SampleFilterSpec from CLI arguments for `pq filter push`. - * Exactly one filter flag must be provided. - */ -function parseFilterSpec(argv: Args): SampleFilterSpec { - // Parse a comma-separated list of function handles (e.g. "f-1,f-2") into funcIndexes. - function parseFuncList(value: string): number[] { - return value.split(',').map((s) => { - const m = /^f-(\d+)$/.exec(s.trim()); - if (!m) { - console.error( - `Error: invalid function handle "${s.trim()}" (expected f-)` - ); - process.exit(1); - } - return parseInt(m[1], 10); - }); - } + pq stop --all` + ); - const flags = [ - 'excludes-function', - 'excludes-any-function', - 'merge', - 'root-at', - 'includes-function', - 'includes-any-function', - 'includes-prefix', - 'includes-suffix', - ] as const; + // Bare `pq` with no arguments + program.action(() => { + console.error('Error: No command specified\n'); + program.outputHelp(); + process.exit(1); + }); - const markerFlags = ['during-marker', 'outside-marker'] as const; - const activeFlags = flags.filter((f) => argv[f] !== undefined); - const activeMarkerFlags = markerFlags.filter((f) => argv[f] === true); + // pq load + addGlobalOptions( + program + .command('load ') + .description('Load a profile and start a daemon session') + ).action(async (profilePath: string, opts) => { + console.log(`Loading profile from ${profilePath}...`); + const sessionId = await startNewDaemon( + SESSION_DIR, + profilePath, + opts.session + ); + console.log(`Session started: ${sessionId}`); + }); - const totalActive = activeFlags.length + activeMarkerFlags.length; - if (totalActive === 0) { - console.error( - 'Error: filter push requires one of: ' + - [...flags, ...markerFlags].map((f) => `--${f}`).join(', ') + // pq status + addGlobalOptions( + program + .command('status') + .description( + 'Show session status (selected thread, zoom ranges, filters)' + ) + ).action(async (opts) => { + const result = await sendCommand( + SESSION_DIR, + { command: 'status' }, + opts.session ); - process.exit(1); - } - if (totalActive > 1) { - console.error('Error: filter push accepts only one filter flag per push'); - process.exit(1); - } + console.log(formatOutput(result, opts.json ?? false)); + }); - if (argv['excludes-function'] !== undefined) { - return { - type: 'excludes-function', - funcIndexes: parseFuncList(argv['excludes-function']), - }; - } - if (argv['excludes-any-function'] !== undefined) { - return { - type: 'excludes-function', - funcIndexes: parseFuncList(argv['excludes-any-function']), - }; - } - if (argv.merge !== undefined) { - return { type: 'merge', funcIndexes: parseFuncList(argv.merge) }; - } - if (argv['root-at'] !== undefined) { - const indexes = parseFuncList(argv['root-at']); - if (indexes.length !== 1) { - console.error('Error: --root-at takes exactly one function handle'); - process.exit(1); - } - return { type: 'root-at', funcIndex: indexes[0] }; - } - if (argv['includes-function'] !== undefined) { - return { - type: 'includes-function', - funcIndexes: parseFuncList(argv['includes-function']), - }; - } - if (argv['includes-any-function'] !== undefined) { - return { - type: 'includes-function', - funcIndexes: parseFuncList(argv['includes-any-function']), - }; - } - if (argv['includes-prefix'] !== undefined) { - return { - type: 'includes-prefix', - funcIndexes: parseFuncList(argv['includes-prefix']), - }; - } - if (argv['includes-suffix'] !== undefined) { - const indexes = parseFuncList(argv['includes-suffix']); - if (indexes.length !== 1) { - console.error( - 'Error: --includes-suffix takes exactly one function handle' + // pq stop [id] + addGlobalOptions( + program + .command('stop [id]') + .description( + 'Stop the current session, a specific session, or all with --all' + ) + .option('--all', 'Stop all running sessions') + ).action(async (idArg: string | undefined, opts) => { + if (opts.all) { + const sessionIds = listSessions(SESSION_DIR); + await Promise.all( + sessionIds.map((id: string) => stopDaemon(SESSION_DIR, id)) ); - process.exit(1); + } else { + const sessionId = idArg ?? opts.session; + await stopDaemon(SESSION_DIR, sessionId); } - return { type: 'includes-suffix', funcIndex: indexes[0] }; - } - if (argv['during-marker'] === true) { - if (!argv.search) { - console.error('Error: --during-marker requires --search '); - process.exit(1); - } - return { type: 'during-marker', searchString: argv.search }; - } - if (argv['outside-marker'] === true) { - if (!argv.search) { - console.error('Error: --outside-marker requires --search '); - process.exit(1); - } - return { type: 'outside-marker', searchString: argv.search }; - } - - // Should not be reachable. - console.error('Error: no valid filter flag found'); - process.exit(1); - throw new Error('unreachable'); -} - -async function main(): Promise { - const argv = minimist(process.argv.slice(2), { - string: [ - 'session', - 'thread', - 'marker', - 'sample', - 'function', - 'search', - 'min-duration', - 'max-duration', - 'min-self', - 'category', - 'limit', - 'group-by', - 'max-lines', - 'scoring', - // filter command flags (value-taking) - 'excludes-function', - 'excludes-any-function', - 'merge', - 'root-at', - 'includes-function', - 'includes-any-function', - 'includes-prefix', - 'includes-suffix', - 'count', - ], - boolean: [ - 'daemon', - 'help', - 'h', - 'guide', - 'all', - 'has-stack', - 'auto-group', - 'json', - // filter command presence flags - 'during-marker', - 'outside-marker', - ], - alias: { h: 'help' }, }); - // Check for help flag - if (argv.help || argv.h) { - printUsage(); - process.exit(0); - } - - // Check for guide flag - if (argv.guide) { - printGuide(); - process.exit(0); - } - - // Internal flag: running as daemon - if (argv.daemon) { - const profilePath = argv._[0]; - if (!profilePath) { - console.error('Error: Profile path required for daemon mode'); - process.exit(1); - } - await startDaemon(SESSION_DIR, profilePath, argv.session); - return; - } + // pq guide + program + .command('guide') + .description('Show detailed usage guide (commands, patterns, tips)') + .action(() => { + console.log(guideText); + }); - // Parse command - const command = argv._[0]; - - if (!command) { - console.error('Error: No command specified\n'); - printUsage(); - process.exit(1); - } + registerProfileCommand(program, SESSION_DIR); + registerThreadCommand(program, SESSION_DIR); + registerMarkerCommand(program, SESSION_DIR); + registerFunctionCommand(program, SESSION_DIR); + registerZoomCommand(program, SESSION_DIR); + registerFilterCommand(program, SESSION_DIR); + registerSessionCommand(program, SESSION_DIR); try { - switch (command) { - case 'help': { - printUsage(); - break; - } - - case 'load': { - const profilePath = argv._[1]; - if (!profilePath) { - console.error('Error: Profile path required for "load" command'); - console.error('Usage: pq load [--session ]'); - process.exit(1); - } - - console.log(`Loading profile from ${profilePath}...`); - const sessionId = await startNewDaemon( - SESSION_DIR, - profilePath, - argv.session - ); - console.log(`Session started: ${sessionId}`); - break; - } - - case 'profile': { - const subcommand = argv._[1] ?? 'info'; - if (subcommand === 'info' || subcommand === 'threads') { - const result = await sendCommand( - SESSION_DIR, - { - command: 'profile', - subcommand, - all: argv.all, - search: argv.search, - }, - argv.session - ); - console.log(formatOutput(result, argv.json || false)); - } else { - console.error(`Error: Unknown command ${command} ${subcommand}`); - process.exit(1); - } - break; - } - - case 'thread': { - const subcommand = argv._[1] ?? 'info'; - // For thread select, get the thread handle from argv._[2] if not provided via --thread flag - const thread = - argv.thread ?? (subcommand === 'select' ? argv._[2] : undefined); - - // Parse marker filter options if this is a markers command - let markerFilters: MarkerFilterOptions | undefined; - let functionFilters: FunctionFilterOptions | undefined; - - if (subcommand === 'markers') { - const hasSearch = argv.search !== undefined; - const hasMinDuration = argv['min-duration'] !== undefined; - const hasMaxDuration = argv['max-duration'] !== undefined; - const hasCategory = argv.category !== undefined; - const hasStack = argv['has-stack']; - const hasLimit = argv.limit !== undefined; - const hasGroupBy = argv['group-by'] !== undefined; - const hasAutoGroup = argv['auto-group']; - - if ( - hasSearch || - hasMinDuration || - hasMaxDuration || - hasCategory || - hasStack || - hasLimit || - hasGroupBy || - hasAutoGroup - ) { - markerFilters = {}; - if (hasSearch) { - markerFilters.searchString = argv.search; - } - if (hasMinDuration) { - const minDuration = parseFloat(argv['min-duration']); - if (isNaN(minDuration) || minDuration < 0) { - console.error( - 'Error: --min-duration must be a positive number (in milliseconds)' - ); - process.exit(1); - } - markerFilters.minDuration = minDuration; - } - if (hasMaxDuration) { - const maxDuration = parseFloat(argv['max-duration']); - if (isNaN(maxDuration) || maxDuration < 0) { - console.error( - 'Error: --max-duration must be a positive number (in milliseconds)' - ); - process.exit(1); - } - markerFilters.maxDuration = maxDuration; - } - if (hasCategory) { - markerFilters.category = argv.category; - } - if (hasStack) { - markerFilters.hasStack = true; - } - if (hasLimit) { - const limit = parseInt(argv.limit, 10); - if (isNaN(limit) || limit <= 0) { - console.error('Error: --limit must be a positive integer'); - process.exit(1); - } - markerFilters.limit = limit; - } - if (hasGroupBy) { - markerFilters.groupBy = argv['group-by']; - } - if (hasAutoGroup) { - markerFilters.autoGroup = true; - } - } - } - - // Parse function filter options if this is a functions command - if (subcommand === 'functions') { - const hasSearch = argv.search !== undefined; - const hasMinSelf = argv['min-self'] !== undefined; - const hasLimit = argv.limit !== undefined; - - if (hasSearch || hasMinSelf || hasLimit) { - functionFilters = {}; - if (hasSearch) { - functionFilters.searchString = argv.search; - } - if (hasMinSelf) { - const minSelf = parseFloat(argv['min-self']); - if (isNaN(minSelf) || minSelf < 0 || minSelf > 100) { - console.error( - 'Error: --min-self must be a number between 0 and 100 (percentage)' - ); - process.exit(1); - } - functionFilters.minSelf = minSelf; - } - if (hasLimit) { - const limit = parseInt(argv.limit, 10); - if (isNaN(limit) || limit <= 0) { - console.error('Error: --limit must be a positive integer'); - process.exit(1); - } - functionFilters.limit = limit; - } - } - } - - // Parse call tree options for samples-top-down and samples-bottom-up - let callTreeOptions: CallTreeCollectionOptions | undefined; - if ( - subcommand === 'samples-top-down' || - subcommand === 'samples-bottom-up' - ) { - const hasMaxLines = argv['max-lines'] !== undefined; - const hasScoring = argv.scoring !== undefined; - - if (hasMaxLines || hasScoring) { - callTreeOptions = {}; - if (hasMaxLines) { - const maxLines = parseInt(String(argv['max-lines']), 10); - if (isNaN(maxLines) || maxLines <= 0) { - console.error('Error: --max-lines must be a positive integer'); - process.exit(1); - } - callTreeOptions.maxNodes = maxLines; - } - if (hasScoring) { - const validStrategies = [ - 'exponential-0.95', - 'exponential-0.92', - 'exponential-0.9', - 'exponential-0.8', - 'harmonic-0.1', - 'harmonic-0.5', - 'harmonic-1.0', - 'percentage-only', - ]; - const scoringValue = argv.scoring; - if (!scoringValue || !validStrategies.includes(scoringValue)) { - console.error( - `Error: --scoring must be one of: ${validStrategies.join(', ')}` - ); - process.exit(1); - } - callTreeOptions.scoringStrategy = - scoringValue as CallTreeScoringStrategy; - } - } - } - - const includeIdle = - (subcommand === 'samples' || - subcommand === 'samples-top-down' || - subcommand === 'samples-bottom-up' || - subcommand === 'functions') && - argv['include-idle'] === true; - - const samplesSearch = - (subcommand === 'samples' || - subcommand === 'samples-top-down' || - subcommand === 'samples-bottom-up') && - typeof argv.search === 'string' - ? (argv.search as string) - : undefined; - - if ( - subcommand === 'info' || - subcommand === 'select' || - subcommand === 'samples' || - subcommand === 'samples-top-down' || - subcommand === 'samples-bottom-up' || - subcommand === 'markers' || - subcommand === 'functions' - ) { - // Parse ephemeral sample filters for commands that support them. - const sampleFilters = - subcommand === 'samples' || - subcommand === 'samples-top-down' || - subcommand === 'samples-bottom-up' || - subcommand === 'functions' - ? parseEphemeralFilters(argv) - : undefined; - - const result = await sendCommand( - SESSION_DIR, - { - command: 'thread', - subcommand, - thread, - includeIdle: includeIdle || undefined, - search: samplesSearch, - markerFilters, - functionFilters, - callTreeOptions, - sampleFilters: sampleFilters?.length ? sampleFilters : undefined, - }, - argv.session - ); - console.log(formatOutput(result, argv.json || false)); - } else { - console.error(`Error: Unknown command ${command} ${subcommand}`); - process.exit(1); - } - break; - } - - case 'marker': { - const subcommand = argv._[1] ?? 'info'; - // For marker commands, get the marker handle from argv._[2] if not provided via --marker flag - const marker = - argv.marker ?? - (subcommand === 'info' || - subcommand === 'select' || - subcommand === 'stack' - ? argv._[2] - : undefined); - if ( - subcommand === 'info' || - subcommand === 'select' || - subcommand === 'stack' - ) { - const result = await sendCommand( - SESSION_DIR, - { command: 'marker', subcommand, marker }, - argv.session - ); - console.log(formatOutput(result, argv.json || false)); - } else { - console.error(`Error: Unknown command ${command} ${subcommand}`); - process.exit(1); - } - break; - } - - case 'sample': { - const subcommand = argv._[1] ?? 'info'; - const sample = argv.sample; - if (subcommand === 'info' || subcommand === 'select') { - const result = await sendCommand( - SESSION_DIR, - { command: 'sample', subcommand, sample }, - argv.session - ); - console.log(formatOutput(result, argv.json || false)); - } else { - console.error(`Error: Unknown command ${command} ${subcommand}`); - process.exit(1); - } - break; - } - - case 'function': { - const subcommand = argv._[1] ?? 'info'; - // For function commands, get the function handle from argv._[2] if not provided via --function flag - const function_ = - argv.function ?? - (subcommand === 'info' || - subcommand === 'expand' || - subcommand === 'select' - ? argv._[2] - : undefined); - if ( - subcommand === 'info' || - subcommand === 'expand' || - subcommand === 'select' - ) { - const result = await sendCommand( - SESSION_DIR, - { command: 'function', subcommand, function: function_ }, - argv.session - ); - console.log(formatOutput(result, argv.json || false)); - } else { - console.error(`Error: Unknown command ${command} ${subcommand}`); - process.exit(1); - } - break; - } - - case 'filter': { - const explicitSubcommand = argv._[1]; - const subcommand = explicitSubcommand ?? 'list'; - - const thread = argv.thread ?? undefined; - - if (subcommand === 'push') { - // Parse the filter spec from flags. - const spec = parseFilterSpec(argv); - const result = await sendCommand( - SESSION_DIR, - { command: 'filter', subcommand: 'push', thread, spec }, - argv.session - ); - console.log(formatOutput(result, argv.json || false)); - } else if (subcommand === 'pop') { - // Accept count as positional arg ("pq filter pop 2") or --count flag. - const rawCount = argv._[2] ?? argv.count; - const count = - rawCount !== undefined ? parseInt(String(rawCount), 10) : 1; - if (isNaN(count) || count <= 0) { - console.error('Error: count must be a positive integer'); - process.exit(1); - } - const result = await sendCommand( - SESSION_DIR, - { command: 'filter', subcommand: 'pop', thread, count }, - argv.session - ); - console.log(formatOutput(result, argv.json || false)); - } else if (subcommand === 'list') { - const result = await sendCommand( - SESSION_DIR, - { command: 'filter', subcommand: 'list', thread }, - argv.session - ); - console.log(formatOutput(result, argv.json || false)); - if (!explicitSubcommand) { - console.log('Other options: pq filter [options]'); - } - } else if (subcommand === 'clear') { - const result = await sendCommand( - SESSION_DIR, - { command: 'filter', subcommand: 'clear', thread }, - argv.session - ); - console.log(formatOutput(result, argv.json || false)); - } else { - console.error(`Error: Unknown command filter ${subcommand}`); - process.exit(1); - } - break; - } - - case 'zoom': { - const subcommand = argv._[1]; - if (!subcommand) { - console.error('Error: zoom command requires a subcommand'); - console.error('Usage: pq zoom [range]'); - process.exit(1); - } - if (subcommand === 'push') { - const range = argv._[2]; - if (!range) { - console.error('Error: zoom push requires a range argument'); - console.error('Usage: pq zoom push '); - console.error('Example: pq zoom push 2.7,3.1'); - process.exit(1); - } - const result = await sendCommand( - SESSION_DIR, - { command: 'zoom', subcommand: 'push', range }, - argv.session - ); - console.log(formatOutput(result, argv.json || false)); - } else if (subcommand === 'pop') { - const result = await sendCommand( - SESSION_DIR, - { command: 'zoom', subcommand: 'pop' }, - argv.session - ); - console.log(formatOutput(result, argv.json || false)); - } else if (subcommand === 'clear') { - const result = await sendCommand( - SESSION_DIR, - { command: 'zoom', subcommand: 'clear' }, - argv.session - ); - console.log(formatOutput(result, argv.json || false)); - } else { - console.error(`Error: Unknown command ${command} ${subcommand}`); - process.exit(1); - } - break; - } - - case 'status': { - const result = await sendCommand( - SESSION_DIR, - { command: 'status' }, - argv.session - ); - console.log(formatOutput(result, argv.json || false)); - break; - } - - case 'stop': { - if (argv.all) { - const sessionIds = listSessions(SESSION_DIR); - await Promise.all( - sessionIds.map((id) => stopDaemon(SESSION_DIR, id)) - ); - } else { - // Accept session id as positional arg (pq stop ) or --session flag - const sessionId = argv._[1] ?? argv.session; - await stopDaemon(SESSION_DIR, sessionId); - } - break; - } - - case 'session': { - const subcommand = argv._[1]; - if (!subcommand || subcommand === 'list') { - const sessionIds = listSessions(SESSION_DIR); - let numCleaned = 0; - const runningSessionMetadata = []; - for (const sessionId of sessionIds) { - const metadata = validateSession(SESSION_DIR, sessionId); - if (metadata === null) { - cleanupSession(SESSION_DIR, sessionId); - numCleaned++; - continue; - } - runningSessionMetadata.push(metadata); - } - - if (numCleaned !== 0) { - console.log(`Cleaned up ${numCleaned} stale sessions.`); - console.log(); - } - runningSessionMetadata.sort( - (a, b) => - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - ); - const currentSessionId = getCurrentSessionId(SESSION_DIR); - console.log( - `Found ${runningSessionMetadata.length} running sessions:` - ); - for (const metadata of runningSessionMetadata) { - const isCurrent = metadata.id === currentSessionId; - const marker = isCurrent ? '* ' : ' '; - console.log( - `${marker}${metadata.id}, created at ${metadata.createdAt} [daemon pid: ${metadata.pid}]` - ); - } - } else if (subcommand === 'use') { - const sessionId = argv._[2]; - if (!sessionId) { - console.error('Error: session use requires a session id'); - console.error('Usage: pq session use '); - process.exit(1); - } - const metadata = validateSession(SESSION_DIR, sessionId); - if (metadata === null) { - console.error( - `Error: session "${sessionId}" not found or not running` - ); - process.exit(1); - } - setCurrentSession(SESSION_DIR, sessionId); - console.log(`Switched to session ${sessionId}`); - } else { - console.error(`Error: Unknown command session ${subcommand}`); - process.exit(1); - } - break; - } - - default: { - console.error(`Error: Unknown command "${command}"\n`); - printUsage(); - process.exit(1); - } - } + await program.parseAsync(process.argv); } catch (error) { console.error(`Error: ${error instanceof Error ? error.message : error}`); process.exit(1); diff --git a/profile-query-cli/src/output.ts b/profile-query-cli/src/output.ts new file mode 100644 index 0000000000..60edaf70cf --- /dev/null +++ b/profile-query-cli/src/output.ts @@ -0,0 +1,79 @@ +/* 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/. */ + +/** + * Output formatting for pq CLI commands. + */ + +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import type { CommandResult } from './protocol'; +import { + formatStatusResult, + formatFunctionExpandResult, + formatFunctionInfoResult, + formatViewRangeResult, + formatFilterStackResult, + formatThreadInfoResult, + formatMarkerStackResult, + formatMarkerInfoResult, + formatProfileInfoResult, + formatThreadSamplesResult, + formatThreadSamplesTopDownResult, + formatThreadSamplesBottomUpResult, + formatThreadMarkersResult, + formatThreadFunctionsResult, +} from './formatters'; + +/** + * Format a command result for output. + * If jsonFlag is true, outputs JSON. Otherwise outputs as plain text. + */ +export function formatOutput( + result: string | CommandResult, + jsonFlag: boolean +): string { + if (jsonFlag) { + if (typeof result === 'string') { + return JSON.stringify({ type: 'text', result }, null, 2); + } + return JSON.stringify(result, null, 2); + } + + if (typeof result === 'string') { + return result; + } + + switch (result.type) { + case 'status': + return formatStatusResult(result); + case 'filter-stack': + return formatFilterStackResult(result); + case 'function-expand': + return formatFunctionExpandResult(result); + case 'function-info': + return formatFunctionInfoResult(result); + case 'view-range': + return formatViewRangeResult(result); + case 'thread-info': + return formatThreadInfoResult(result); + case 'marker-stack': + return formatMarkerStackResult(result); + case 'marker-info': + return formatMarkerInfoResult(result); + case 'profile-info': + return formatProfileInfoResult(result); + case 'thread-samples': + return formatThreadSamplesResult(result); + case 'thread-samples-top-down': + return formatThreadSamplesTopDownResult(result); + case 'thread-samples-bottom-up': + return formatThreadSamplesBottomUpResult(result); + case 'thread-markers': + return formatThreadMarkersResult(result); + case 'thread-functions': + return formatThreadFunctionsResult(result); + default: + throw assertExhaustiveCheck(result); + } +} diff --git a/profile-query-cli/src/utils/parse.ts b/profile-query-cli/src/utils/parse.ts new file mode 100644 index 0000000000..d9f5612723 --- /dev/null +++ b/profile-query-cli/src/utils/parse.ts @@ -0,0 +1,221 @@ +/* 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/. */ + +/** + * Shared argument parsing utilities for pq CLI commands. + */ + +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import type { SampleFilterSpec } from '../protocol'; + +/** + * Accumulator for Commander repeated options (--flag a --flag b → ['a', 'b']). + */ +export function collectStrings(val: string, prev: string[]): string[] { + return [...prev, val]; +} + +/** + * Parse a comma-separated list of function handles (e.g. "f-1,f-2") into numeric indexes. + */ +export function parseFuncList(value: string): number[] { + return value.split(',').map((s) => { + const m = /^f-(\d+)$/.exec(s.trim()); + if (!m) { + console.error( + `Error: invalid function handle "${s.trim()}" (expected f-)` + ); + process.exit(1); + } + return parseInt(m[1], 10); + }); +} + +/** + * Options bag produced by Commander for commands that support ephemeral sample filters. + * Keys are camelCase because Commander normalises hyphenated option names. + */ +export interface EphemeralFilterOpts { + excludesFunction?: string[]; + excludesAnyFunction?: string[]; + merge?: string[]; + rootAt?: string[]; + includesFunction?: string[]; + includesAnyFunction?: string[]; + includesPrefix?: string[]; + includesSuffix?: string[]; + duringMarker?: boolean; + outsideMarker?: boolean; + search?: string; +} + +/** + * Parse zero or more ephemeral SampleFilterSpecs from CLI options. + * Multiple flags are collected in order; each produces one spec. + * The same flag may be repeated (e.g. --merge f-1 --merge f-2) to apply it multiple times. + */ +export function parseEphemeralFilters( + opts: EphemeralFilterOpts +): SampleFilterSpec[] { + const specs: SampleFilterSpec[] = []; + + for (const v of opts.excludesFunction ?? []) { + specs.push({ type: 'excludes-function', funcIndexes: parseFuncList(v) }); + } + for (const v of opts.excludesAnyFunction ?? []) { + specs.push({ type: 'excludes-function', funcIndexes: parseFuncList(v) }); + } + for (const v of opts.merge ?? []) { + specs.push({ type: 'merge', funcIndexes: parseFuncList(v) }); + } + for (const v of opts.rootAt ?? []) { + const indexes = parseFuncList(v); + if (indexes.length !== 1) { + console.error('Error: --root-at takes exactly one function handle'); + process.exit(1); + } + specs.push({ type: 'root-at', funcIndex: indexes[0] }); + } + for (const v of opts.includesFunction ?? []) { + specs.push({ type: 'includes-function', funcIndexes: parseFuncList(v) }); + } + for (const v of opts.includesAnyFunction ?? []) { + specs.push({ type: 'includes-function', funcIndexes: parseFuncList(v) }); + } + for (const v of opts.includesPrefix ?? []) { + specs.push({ type: 'includes-prefix', funcIndexes: parseFuncList(v) }); + } + for (const v of opts.includesSuffix ?? []) { + const indexes = parseFuncList(v); + if (indexes.length !== 1) { + console.error( + 'Error: --includes-suffix takes exactly one function handle' + ); + process.exit(1); + } + specs.push({ type: 'includes-suffix', funcIndex: indexes[0] }); + } + if (opts.duringMarker === true) { + if (!opts.search) { + console.error('Error: --during-marker requires --search '); + process.exit(1); + } + specs.push({ type: 'during-marker', searchString: opts.search }); + } + if (opts.outsideMarker === true) { + if (!opts.search) { + console.error('Error: --outside-marker requires --search '); + process.exit(1); + } + specs.push({ type: 'outside-marker', searchString: opts.search }); + } + + return specs; +} + +/** + * Parse exactly one SampleFilterSpec from CLI options for `pq filter push`. + * Exactly one filter flag must be provided. + */ +export function parseFilterSpec(opts: EphemeralFilterOpts): SampleFilterSpec { + const valueFlags = [ + 'excludesFunction', + 'excludesAnyFunction', + 'merge', + 'rootAt', + 'includesFunction', + 'includesAnyFunction', + 'includesPrefix', + 'includesSuffix', + ] as const; + const markerFlags = ['duringMarker', 'outsideMarker'] as const; + + const activeValueFlags = valueFlags.filter( + (f) => opts[f] !== undefined && (opts[f] as string[]).length > 0 + ); + const activeMarkerFlags = markerFlags.filter((f) => opts[f] === true); + const totalActive = activeValueFlags.length + activeMarkerFlags.length; + + if (totalActive === 0) { + const allFlags = [ + '--excludes-function', + '--excludes-any-function', + '--merge', + '--root-at', + '--includes-function', + '--includes-any-function', + '--includes-prefix', + '--includes-suffix', + '--during-marker', + '--outside-marker', + ]; + console.error('Error: filter push requires one of: ' + allFlags.join(', ')); + process.exit(1); + } + if (totalActive > 1) { + console.error('Error: filter push accepts only one filter flag per push'); + process.exit(1); + } + + if (activeValueFlags.length > 0) { + const flag = activeValueFlags[0]; + const values = opts[flag] as string[]; + // Each repeated flag produces one entry; for filter push there should be exactly one value + const value = values[0]; + + switch (flag) { + case 'excludesFunction': + case 'excludesAnyFunction': + return { type: 'excludes-function', funcIndexes: parseFuncList(value) }; + case 'merge': + return { type: 'merge', funcIndexes: parseFuncList(value) }; + case 'rootAt': { + const indexes = parseFuncList(value); + if (indexes.length !== 1) { + console.error('Error: --root-at takes exactly one function handle'); + process.exit(1); + } + return { type: 'root-at', funcIndex: indexes[0] }; + } + case 'includesFunction': + case 'includesAnyFunction': + return { type: 'includes-function', funcIndexes: parseFuncList(value) }; + case 'includesPrefix': + return { type: 'includes-prefix', funcIndexes: parseFuncList(value) }; + case 'includesSuffix': { + const indexes = parseFuncList(value); + if (indexes.length !== 1) { + console.error( + 'Error: --includes-suffix takes exactly one function handle' + ); + process.exit(1); + } + return { type: 'includes-suffix', funcIndex: indexes[0] }; + } + default: + throw assertExhaustiveCheck(flag); + } + } + + // Marker flags + if (opts.duringMarker === true) { + if (!opts.search) { + console.error('Error: --during-marker requires --search '); + process.exit(1); + } + return { type: 'during-marker', searchString: opts.search }; + } + if (opts.outsideMarker === true) { + if (!opts.search) { + console.error('Error: --outside-marker requires --search '); + process.exit(1); + } + return { type: 'outside-marker', searchString: opts.search }; + } + + // Should not be reachable. + console.error('Error: no valid filter flag found'); + process.exit(1); + throw new Error('unreachable'); +} diff --git a/yarn.lock b/yarn.lock index 3ef98d3b9a..418a8ac2d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3657,6 +3657,11 @@ command-line-usage@^7.0.3: table-layout "^4.1.0" typical "^7.1.1" +commander@14.0.3: + version "14.0.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.3.tgz#425d79b48f9af82fcd9e4fc1ea8af6c5ec07bbc2" + integrity sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== + commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" @@ -10266,7 +10271,16 @@ string-length@^4.0.2: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10396,7 +10410,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -10410,6 +10424,13 @@ strip-ansi@^0.3.0: dependencies: ansi-regex "^0.2.1" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1, strip-ansi@^7.1.0, strip-ansi@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -11783,7 +11804,16 @@ workbox-window@7.4.0, workbox-window@^7.4.0: "@types/trusted-types" "^2.0.2" workbox-core "7.4.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From a65f5b2211f30d7ecd0d7f3bf3dd9923c6b89f91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 13 Apr 2026 02:19:43 +0200 Subject: [PATCH 34/63] Guide updates --- profile-query-cli/guide.txt | 147 +++++++++++++++++++++++++-------- profile-query-cli/schemas.txt | 121 +++++++++++++++++++++++++++ profile-query-cli/src/index.ts | 9 ++ 3 files changed, 244 insertions(+), 33 deletions(-) create mode 100644 profile-query-cli/schemas.txt diff --git a/profile-query-cli/guide.txt b/profile-query-cli/guide.txt index 80f650433a..7f2c130353 100644 --- a/profile-query-cli/guide.txt +++ b/profile-query-cli/guide.txt @@ -4,6 +4,17 @@ pq (Profile Querier) queries Firefox performance profiles loaded into a persiste daemon. Load a profile once, then query it interactively without reloading. +QUICK START + + pq load profile.json.gz + pq profile info Find threads; note handles (e.g. t-0, t-1) + pq thread select t-0 + pq thread samples Hot functions by self time + pq thread samples-top-down Full top-down call tree + + The rest of this guide covers filtering, zooming, markers, and advanced analysis. + + SESSION MODEL pq uses a daemon/session model: @@ -13,7 +24,7 @@ SESSION MODEL The daemon persists your selected thread, zoom ranges, and filter stacks across queries. - For automation or concurrent work, prefer an explicit session ID so each workflow + For scripting or concurrent work, prefer an explicit session ID so each workflow gets isolated state: pq load profile.json.gz --session run-a pq profile info --session run-a @@ -25,14 +36,14 @@ CORE WORKFLOW Step 1: Load a profile pq load profile.json.gz - pq load profile.json.gz --session run-a Recommended for automation or concurrent work + pq load profile.json.gz --session run-a Recommended for scripting or concurrent work Step 2: Explore the profile pq profile info Overview: processes, threads, time range, CPU activity pq profile info --all Show all processes and threads (not just top 5) pq profile info --search GeckoMain Filter by process/thread name, pid, or tid - Step 3: Select a thread to analyze (requires Step 2 -- profile info builds thread handles) + Step 3: Select a thread to analyze pq thread select t-0 Select a specific thread (persists for future commands) Step 4: Analyze CPU usage @@ -77,6 +88,17 @@ CORE WORKFLOW pq filter clear Remove all filters +SESSION STATE + + Three pieces of mutable state persist across commands: + + selected_thread set by "thread select"; required by all thread/* commands + zoom_stack set by zoom push/pop/clear; restricts all queries to a time window + filter_stack[t] set by filter push/pop/clear; per-thread, independent stacks + + Use "pq status" to inspect all session state at any time. + + HANDLE SYSTEM Handles are short IDs shown in command output that reference specific items: @@ -85,9 +107,19 @@ HANDLE SYSTEM f-12 Function handles (from "thread samples", "thread functions") ts-6 Timestamp handles (named points in time, usable with "zoom push") - Thread and marker handles are stable within a session. - Function handles (f-N) are stable across sessions for the same profile -- - they are direct indices into the shared function table. + Handle lifetime and stability: + + Handle Populated by Stable across sessions? + ────────────────────────────────────────────────────────────────────────── + t-N profile info Yes, if the same profile is loaded + m-N thread markers No -- rebuilt each time the daemon starts + f-N thread samples, Yes -- direct index into the profile's function + thread functions table; same profile always yields the same f-N + ts-N thread markers No -- position-based, session-scoped + ────────────────────────────────────────────────────────────────────────── + + Function handles (f-N) can be saved and reused across sessions for the same profile. + For all other handles, re-run the command that generates them after each daemon restart. ZOOM: FOCUS ON A TIME RANGE @@ -171,11 +203,14 @@ FILTERS JSON OUTPUT - Add --json to any command to get structured JSON output, useful for piping to jq: + Add --json to any command to get structured JSON output, suitable for piping to jq + or processing programmatically. + + pq thread samples --json | jq '.topFunctionsBySelf[0]' + pq thread markers --json | jq '[.byType[] | select(.durationStats.max > 50)]' + pq profile info --json | jq '.processes[].threads[] | {handle: .threadHandle, name}' - pq thread samples --json | jq '.rows[0]' - pq thread markers --json | jq '[.rows[] | select(.totalDuration > 50)]' - pq profile info --json | jq '.threads | length' + Run "pq schemas" for the full JSON schema reference. COMMON ANALYSIS PATTERNS @@ -266,28 +301,49 @@ UNDERSTANDING THE OUTPUT TIPS - - Start with "profile info" to understand what's in the profile; use --all to see every - process and thread, or --search to filter by name, pid, or tid - - Select a thread before querying -- GeckoMain is usually the main thread + - Start with "profile info"; GeckoMain is usually the main thread -- use + "--search GeckoMain" to find it quickly - Self time is more actionable: focus on high self-time functions for optimization - - Samples percentages are relative to active (non-idle) time by default. Use --include-idle - to see wall-time percentages. Idle time is not a finding on its own -- only investigate it - during a period the thread should be busy (e.g. inside a zoom), which may indicate lock - contention or blocking on another thread. - - Use --search to narrow the call tree to branches you care about: --search GC shows only - call paths that pass through a GC-related function - - Zoom into slow periods: use "zoom push m-" to focus on a slow marker - - Try filters ephemerally first (as flags on thread commands) before committing with "filter push" - - "filter push --merge" is great for hiding allocator/profiler noise without losing context - - "filter push --root-at f-N" + "filter push --includes-function f-N" tightly focuses - analysis on one function's work - - "filter push --during-marker --search X" is powerful for correlating CPU work with events - - Sticky filters compose: each push narrows further (AND semantics across pushes) - - "filter list" shows filters in push order; "filter pop" removes from the end - - Use --json when you need to process or filter results programmatically - - Marker handles are stable within a session; function handles (f-N) are stable across sessions - - Check "status": run "pq status" to confirm selected thread, active zoom, and active filters + - Idle time alone is not a finding: only investigate idle during a period when the + thread should be busy (e.g. inside a zoom on a jank marker), which may indicate + lock contention or blocking on another thread + - --search narrows call PATHS: "samples-top-down --search GC" shows only call + paths that pass through a GC-related function, not just functions named "GC" + - Try filters ephemerally first (as flags on thread commands) before committing + with "filter push" + - "filter push --during-marker --search X" is powerful for correlating CPU work + with specific event types + - Check "pq status" after any state changes to confirm selected thread, active + zoom, and active filters before running analysis + +SCRIPTING + + When using pq in scripts or pipelines: + + Always use a named session for isolation: + pq load profile.json.gz --session my-analysis + pq profile info --session my-analysis + pq thread select t-0 --session my-analysis + + Always use --json for reliable output parsing. Plain text output is for human + reading and may change; the JSON schema is stable. + + Prerequisite chain -- commands depend on prior state: + load → thread select → analysis commands + (run "profile info" to discover thread handles before selecting) + + Extracting handles with jq: + # Find the GeckoMain thread handle + pq profile info --json | \ + jq -r '.processes[].threads[] | select(.name | test("GeckoMain")) | .threadHandle' + + # Get the top self-time function handle + pq thread samples --json | jq -r '.topFunctionsBySelf[0].functionHandle' + + # List marker handles for markers longer than 50ms + pq thread markers --json | \ + jq -r '[.byType[].topMarkers[] | select(.duration > 50) | .handle][]' SESSION MANAGEMENT @@ -302,6 +358,31 @@ SESSION MANAGEMENT ERROR HANDLING - "No running session" Run "pq load " first - "Thread not found" Run "pq profile info" to see valid thread handles - "Marker not found" Handles are session-scoped; reload if session restarted + "No running session" + Run "pq load " first. If using a named session, ensure --session matches. + Run "pq session list" to see what sessions are currently active. + + "Thread not found" + The handle does not exist in this profile. Run "pq profile info" to see valid + thread handles, then re-run "pq thread select" with a handle from that list. + + "No thread selected" + Run "pq thread select " before querying thread data. Use "pq profile info" + first to get valid handles. + + "Marker not found" + Marker handles (m-N) are session-scoped. If the daemon was restarted, re-run + "pq thread markers" to get fresh handles for the new session. + + "Function not found" + Function handles (f-N) are stable across sessions for the same profile, but only + valid after the profile is loaded. Verify the correct profile is loaded with + "pq status" (check the profile path). + + "Session not found" / "No such session" + The session has exited or was stopped. Run "pq session list" to see active sessions. + Run "pq load --session " to start a new session with that ID. + + "Profile load failed" + Check that the path is correct and the file is a valid supported profile format. + The daemon log at ~/.pq/.log contains the full error detail. diff --git a/profile-query-cli/schemas.txt b/profile-query-cli/schemas.txt new file mode 100644 index 0000000000..598879ef4c --- /dev/null +++ b/profile-query-cli/schemas.txt @@ -0,0 +1,121 @@ +pq: JSON Output Schemas + +Add --json to any command to get structured JSON output. The schemas below +document the exact fields returned by each command. + + +pq profile info --json + { + type: "profile-info", + name, platform, threadCount, processCount, + processes: [{ + pid, name, cpuMs, + threads: [{ threadHandle, threadIndex, name, tid, cpuMs }], + remainingThreads?: { count, combinedCpuMs, maxCpuMs } + }], + remainingProcesses?: { count, combinedCpuMs, maxCpuMs }, + context: SessionContext + } + +pq thread samples --json + { + type: "thread-samples", + threadHandle, friendlyThreadName, activeOnly?, + topFunctionsBySelf: [{ functionHandle, functionIndex, name, nameWithLibrary, + library?, selfSamples, selfPercentage, + totalSamples, totalPercentage }], + topFunctionsByTotal: [ ...same shape... ], + heaviestStack: { + selfSamples, frameCount, + frames: [{ name, nameWithLibrary, library?, + selfSamples, selfPercentage, totalSamples, totalPercentage }] + }, + activeFilters?, ephemeralFilters?, + context: SessionContext + } + +pq thread samples-top-down --json + { + type: "thread-samples-top-down", + threadHandle, friendlyThreadName, activeOnly?, + regularCallTree: CallTreeNode, + activeFilters?, ephemeralFilters?, + context: SessionContext + } + +pq thread samples-bottom-up --json + { + type: "thread-samples-bottom-up", + threadHandle, friendlyThreadName, activeOnly?, + invertedCallTree: CallTreeNode | null, + activeFilters?, ephemeralFilters?, + context: SessionContext + } + +CallTreeNode (recursive): + { + name, nameWithLibrary, library?, + functionHandle?, functionIndex?, + totalSamples, totalPercentage, selfSamples, selfPercentage, + originalDepth, + children: [CallTreeNode], + childrenTruncated?: { count, combinedSamples, combinedPercentage, + maxSamples, maxPercentage, depth } + } + +pq thread markers --json + { + type: "thread-markers", + threadHandle, friendlyThreadName, + totalMarkerCount, filteredMarkerCount, + byType: [{ + markerName, count, isInterval, + durationStats?: { min, max, avg, median, p95, p99 }, + rateStats?: { markersPerSecond, minGap, avgGap, maxGap }, + topMarkers: [{ handle, label, start, duration?, hasStack? }], + subGroups?: [ ...MarkerGroupData... ] + }], + byCategory: [{ categoryName, categoryIndex, count, percentage }], + customGroups?: [ ...MarkerGroupData... ], + context: SessionContext + } + +pq thread functions --json + { + type: "thread-functions", + threadHandle, friendlyThreadName, activeOnly?, + totalFunctionCount, filteredFunctionCount, + functions: [{ functionHandle, name, nameWithLibrary, library?, + selfSamples, selfPercentage, totalSamples, totalPercentage, + fullSelfPercentage?, fullTotalPercentage? }], + activeFilters?, ephemeralFilters?, + context: SessionContext + } + +pq marker info --json + { + type: "marker-info", + threadHandle, friendlyThreadName, markerHandle, markerIndex, name, + category: { index, name }, + start, end, duration?, + fields?: [{ key, label, value, formattedValue }], + stack?: { frames: [{ name, nameWithLibrary }], truncated } + } + +pq status --json + { + type: "status", + selectedThreadHandle, + selectedThreads: [{ threadIndex, name }], + viewRanges: [{ start, startName, end, endName }], + rootRange: { start, end }, + filterStacks: [{ threadHandle, filters: FilterEntry[] }] + } + +SessionContext (present on all command results): + { + selectedThreadHandle, + selectedThreads: [{ threadIndex, name }], + currentViewRange: { start, startName, end, endName } | null, + rootRange: { start, end } + } diff --git a/profile-query-cli/src/index.ts b/profile-query-cli/src/index.ts index 6cf294b462..f3180fae8e 100644 --- a/profile-query-cli/src/index.ts +++ b/profile-query-cli/src/index.ts @@ -26,6 +26,7 @@ import * as path from 'path'; import * as os from 'os'; import { Command } from 'commander'; import guideText from '../guide.txt'; +import schemasText from '../schemas.txt'; import { startDaemon } from './daemon'; import { startNewDaemon, stopDaemon, sendCommand } from './client'; import { listSessions } from './session'; @@ -150,6 +151,14 @@ Examples: console.log(guideText); }); + // pq schemas + program + .command('schemas') + .description('Show JSON output schemas for all commands') + .action(() => { + console.log(schemasText); + }); + registerProfileCommand(program, SESSION_DIR); registerThreadCommand(program, SESSION_DIR); registerMarkerCommand(program, SESSION_DIR); From 899c7c57ffbc1244befa388d77158c7bfe4310c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 13 Apr 2026 12:45:57 +0200 Subject: [PATCH 35/63] Add tests for marker-info formatter --- .../marker-info-formatters.test.ts | 384 ++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 src/test/unit/profile-query/marker-info-formatters.test.ts diff --git a/src/test/unit/profile-query/marker-info-formatters.test.ts b/src/test/unit/profile-query/marker-info-formatters.test.ts new file mode 100644 index 0000000000..d88f95a6ab --- /dev/null +++ b/src/test/unit/profile-query/marker-info-formatters.test.ts @@ -0,0 +1,384 @@ +/* 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/. */ + +import { + collectMarkerInfo, + collectMarkerStack, + formatMarkerInfo, + formatMarkerStackFull, + formatThreadMarkers, +} from 'firefox-profiler/profile-query/formatters/marker-info'; +import { MarkerMap } from 'firefox-profiler/profile-query/marker-map'; +import { ThreadMap } from 'firefox-profiler/profile-query/thread-map'; +import { + getProfileWithMarkers, + getProfileFromTextSamples, +} from '../../fixtures/profiles/processed-profile'; +import { storeWithProfile } from '../../fixtures/stores'; +import { StringTable } from 'firefox-profiler/utils/string-table'; +import { INTERVAL } from 'firefox-profiler/app-logic/constants'; + +/** + * Sets up a store, threadMap, and markerMap for a single-thread profile with + * the given markers. Returns helpers to register marker handles. + */ +function setupWithMarkers( + markers: Parameters[0] +) { + const profile = getProfileWithMarkers(markers); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + const markerMap = new MarkerMap(); + threadMap.handleForThreadIndex(0); + + function registerMarker(markerIndex: number): string { + return markerMap.handleForMarker(new Set([0]), markerIndex); + } + + return { store, threadMap, markerMap, registerMarker }; +} + +describe('formatThreadMarkers', function () { + it('shows interval markers with duration stats', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, 10, { type: 'DOMEvent', eventType: 'click', latency: 5 }], + [ + 'DOMEvent', + 20, + 40, + { type: 'DOMEvent', eventType: 'click', latency: 5 }, + ], + ]); + + const result = formatThreadMarkers(store, threadMap, markerMap); + + expect(result).toContain('2 markers'); + expect(result).toContain('DOMEvent'); + // Interval markers should show duration stats, not "(instant)" + expect(result).toContain('interval'); + expect(result).not.toContain('(instant)'); + }); + + it('shows instant markers with (instant) label', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, null, { type: 'DOMEvent', eventType: 'click' }], + ['DOMEvent', 5, null, { type: 'DOMEvent', eventType: 'keydown' }], + ]); + + const result = formatThreadMarkers(store, threadMap, markerMap); + + expect(result).toContain('(instant)'); + }); + + it('shows filtered count annotation when search reduces marker count', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, 5, { type: 'DOMEvent', eventType: 'click', latency: 1 }], + [ + 'UserTiming', + 10, + 15, + { type: 'UserTiming', name: 'myMark', entryType: 'measure' }, + ], + [ + 'DOMEvent', + 20, + 25, + { type: 'DOMEvent', eventType: 'keydown', latency: 2 }, + ], + ]); + + const result = formatThreadMarkers(store, threadMap, markerMap, undefined, { + searchString: 'DOMEvent', + }); + + // Should show 2 matches filtered from 3 total + expect(result).toContain('2 markers'); + expect(result).toContain('(filtered from 3)'); + }); + + it('shows nested sub-groups when groupBy specifies multiple keys', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, 2, { type: 'DOMEvent', eventType: 'click', latency: 1 }], + [ + 'DOMEvent', + 3, + 5, + { type: 'DOMEvent', eventType: 'keydown', latency: 1 }, + ], + ['DOMEvent', 6, 8, { type: 'DOMEvent', eventType: 'click', latency: 1 }], + ]); + + const result = formatThreadMarkers(store, threadMap, markerMap, undefined, { + groupBy: 'type,field:eventType', + }); + + // Top-level group for DOMEvent, with sub-groups for click and keydown + expect(result).toContain('DOMEvent'); + expect(result).toContain('click'); + expect(result).toContain('keydown'); + // click sub-group appears before keydown because it has more markers + const clickPos = result.indexOf('click'); + const keydownPos = result.indexOf('keydown'); + expect(clickPos).toBeLessThan(keydownPos); + }); + + it('shows overflow notice when more than 15 groups exist', function () { + // Create 16 markers with distinct names so each forms its own group + const markers: Parameters[0] = Array.from( + { length: 16 }, + (_, i): [string, number, number, Record] => [ + `Marker${i}`, + i * 10, + i * 10 + 5, + { type: `Marker${i}` }, + ] + ); + const { store, threadMap, markerMap } = setupWithMarkers(markers); + + const result = formatThreadMarkers(store, threadMap, markerMap); + + expect(result).toContain('more types'); + }); +}); + +describe('formatMarkerInfo', function () { + it('shows schema fields for a marker with data', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + ['DOMEvent', 0, 10, { type: 'DOMEvent', eventType: 'click', latency: 5 }], + ]); + const handle = registerMarker(0); + + const result = formatMarkerInfo(store, markerMap, threadMap, handle); + + expect(result).toContain('DOMEvent'); + // Schema field label: "Event Type" with value "click" + expect(result).toContain('Event Type'); + expect(result).toContain('click'); + // Schema field label: "Latency" with value for latency + expect(result).toContain('Latency'); + }); + + it('shows duration for interval markers and "instant" for instant markers', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + ['DOMEvent', 0, 20, { type: 'DOMEvent', eventType: 'click', latency: 1 }], + ['DOMEvent', 30, null, { type: 'DOMEvent', eventType: 'hover' }], + ]); + const intervalHandle = registerMarker(0); + const instantHandle = registerMarker(1); + + const intervalResult = formatMarkerInfo( + store, + markerMap, + threadMap, + intervalHandle + ); + const instantResult = formatMarkerInfo( + store, + markerMap, + threadMap, + instantHandle + ); + + // Interval: shows duration in ms + expect(intervalResult).toContain('20.00ms'); + // Instant: shows "(instant)" label + expect(instantResult).toContain('(instant)'); + }); + + it('excludes hidden fields from output', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + [ + 'MarkerWithHiddenField', + 0, + 5, + { type: 'MarkerWithHiddenField', hiddenString: 'secret-value' }, + ], + ]); + const handle = registerMarker(0); + + const result = formatMarkerInfo(store, markerMap, threadMap, handle); + + expect(result).not.toContain('secret-value'); + expect(result).not.toContain('Hidden string'); + }); + + it('shows schema description when available', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + [ + 'UserTiming', + 0, + 10, + { type: 'UserTiming', name: 'myMeasure', entryType: 'measure' }, + ], + ]); + const handle = registerMarker(0); + + const result = formatMarkerInfo(store, markerMap, threadMap, handle); + + // UserTiming has a schema description + expect(result).toContain('performance.mark()'); + }); +}); + +describe('collectMarkerInfo', function () { + it('returns structured data with correct fields for an interval marker', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + [ + 'DOMEvent', + 10, + 30, + { type: 'DOMEvent', eventType: 'click', latency: 5 }, + ], + ]); + const handle = registerMarker(0); + + const result = collectMarkerInfo(store, markerMap, threadMap, handle); + + expect(result.type).toBe('marker-info'); + expect(result.name).toBe('DOMEvent'); + expect(result.markerType).toBe('DOMEvent'); + expect(result.start).toBe(10); + expect(result.end).toBe(30); + expect(result.duration).toBe(20); + // Fields from schema + expect(result.fields).toBeDefined(); + const eventTypeField = result.fields!.find((f) => f.key === 'eventType'); + expect(eventTypeField).toBeDefined(); + expect(eventTypeField!.label).toBe('Event Type'); + expect(eventTypeField!.value).toBe('click'); + }); + + it('returns undefined duration for instant markers', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + ['DOMEvent', 5, null, { type: 'DOMEvent', eventType: 'scroll' }], + ]); + const handle = registerMarker(0); + + const result = collectMarkerInfo(store, markerMap, threadMap, handle); + + expect(result.end).toBeNull(); + expect(result.duration).toBeUndefined(); + }); + + it('excludes hidden fields from result', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + [ + 'MarkerWithHiddenField', + 0, + 5, + { type: 'MarkerWithHiddenField', hiddenString: 'secret' }, + ], + ]); + const handle = registerMarker(0); + + const result = collectMarkerInfo(store, markerMap, threadMap, handle); + + // Hidden fields should not appear + const hiddenField = result.fields?.find((f) => f.key === 'hiddenString'); + expect(hiddenField).toBeUndefined(); + }); +}); + +describe('collectMarkerStack and formatMarkerStackFull', function () { + describe('for a marker without a cause', function () { + it('collectMarkerStack returns null stack', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + [ + 'DOMEvent', + 0, + 5, + { type: 'DOMEvent', eventType: 'click', latency: 1 }, + ], + ]); + const handle = registerMarker(0); + + const result = collectMarkerStack(store, markerMap, threadMap, handle); + + expect(result.type).toBe('marker-stack'); + expect(result.markerName).toBe('DOMEvent'); + expect(result.stack).toBeNull(); + }); + + it('formatMarkerStackFull reports no stack trace', function () { + const { store, threadMap, markerMap, registerMarker } = setupWithMarkers([ + [ + 'DOMEvent', + 0, + 5, + { type: 'DOMEvent', eventType: 'click', latency: 1 }, + ], + ]); + const handle = registerMarker(0); + + const result = formatMarkerStackFull(store, markerMap, threadMap, handle); + + expect(result).toContain('DOMEvent'); + expect(result).toContain('no stack trace'); + }); + }); + + describe('for a marker with a cause stack', function () { + function setupWithCauseStack() { + const { profile } = getProfileFromTextSamples(` + rootFunc + leafFunc + `); + // profile.threads[0] has a stack table from the text samples + const thread = profile.threads[0]; + // samples.stack[0] is the stack index for the leaf frame of the first sample + const stackIndex = thread.samples.stack[0]; + + if (stackIndex === null || stackIndex === undefined) { + throw new Error('Expected a non-null stack index from text samples'); + } + + // Add a marker with a cause pointing to that stack + const stringTable = StringTable.withBackingArray( + profile.shared.stringArray + ); + const markerNameIdx = stringTable.indexForString('TestMarker'); + thread.markers.name.push(markerNameIdx); + thread.markers.startTime.push(1); + thread.markers.endTime.push(5); + thread.markers.phase.push(INTERVAL); + thread.markers.category.push(0); + thread.markers.data.push({ + type: 'Text', + name: 'TestMarker', + cause: { stack: stackIndex }, + }); + thread.markers.length++; + + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + const markerMap = new MarkerMap(); + threadMap.handleForThreadIndex(0); + // The marker we added is at the end of the marker list, but after processing + // markers are time-sorted. Since our marker is at time 1 and there are no + // other markers, it will be at index 0. + const handle = markerMap.handleForMarker(new Set([0]), 0); + return { store, threadMap, markerMap, handle }; + } + + it('collectMarkerStack returns stack frames', function () { + const { store, threadMap, markerMap, handle } = setupWithCauseStack(); + + const result = collectMarkerStack(store, markerMap, threadMap, handle); + + expect(result.stack).not.toBeNull(); + expect(result.stack!.frames.length).toBeGreaterThan(0); + // The leaf frame should be "leafFunc" + expect(result.stack!.frames[0].name).toBe('leafFunc'); + }); + + it('formatMarkerStackFull shows numbered frames', function () { + const { store, threadMap, markerMap, handle } = setupWithCauseStack(); + + const result = formatMarkerStackFull(store, markerMap, threadMap, handle); + + expect(result).toContain('[1]'); + expect(result).toContain('leafFunc'); + }); + }); +}); From c2b299355ffd4a84f55c45a10537a8ca61a87de2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 13 Apr 2026 19:27:21 +0200 Subject: [PATCH 36/63] Update the --search documentation to prevent pitfalls --- profile-query-cli/README.md | 2 +- profile-query-cli/guide.txt | 11 +++++++ profile-query-cli/src/commands/thread.ts | 5 ++- profile-query-cli/src/formatters.ts | 40 ++++++++++++++++++++++-- 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/profile-query-cli/README.md b/profile-query-cli/README.md index 2d2cb9e0da..a019752548 100644 --- a/profile-query-cli/README.md +++ b/profile-query-cli/README.md @@ -68,7 +68,7 @@ Run `pq guide` for a detailed usage guide with patterns and tips. Run `pq --help | Flag | Description | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--thread ` | Specify thread (e.g., `t-0`) | -| `--search ` | Filter results by substring | +| `--search ` | Filter results by substring. For samples commands, comma-separates multiple terms that all must match (AND); `\|` is literal, not OR. | | `--include-idle` | Include idle samples (excluded by default in samples commands) | | `--json` | Output as JSON (for use with `jq`, etc.) | | `--limit ` | Limit number of results shown | diff --git a/profile-query-cli/guide.txt b/profile-query-cli/guide.txt index 7f2c130353..205d727440 100644 --- a/profile-query-cli/guide.txt +++ b/profile-query-cli/guide.txt @@ -60,6 +60,14 @@ CORE WORKFLOW pq thread samples-bottom-up --search "JS::Compile" pq thread samples --search malloc + Search syntax for samples commands: + - Matches any frame in the call stack (case-insensitive substring) + - Comma joins multiple terms with AND: both must appear somewhere in the stack + pq thread samples-top-down --search "GC,malloc" paths through both GC and malloc + - "|" is a literal character, not OR — it will match nothing unless a function + name actually contains "|" + - To see results matching either of two terms, run two separate searches + Step 5: Analyze markers (browser events, timers, etc.) pq thread markers All markers with aggregated stats pq thread markers --category Layout Filter by category @@ -309,6 +317,9 @@ TIPS lock contention or blocking on another thread - --search narrows call PATHS: "samples-top-down --search GC" shows only call paths that pass through a GC-related function, not just functions named "GC" + - --search with comma is AND, not OR: "--search foo,bar" keeps only samples where + BOTH "foo" and "bar" appear somewhere in the stack; to get OR behavior, run two + separate searches; "|" is a literal character and will match nothing - Try filters ephemerally first (as flags on thread commands) before committing with "filter push" - "filter push --during-marker --search X" is powerful for correlating CPU work diff --git a/profile-query-cli/src/commands/thread.ts b/profile-query-cli/src/commands/thread.ts index 3ed9dd48ed..2aa06e6aff 100644 --- a/profile-query-cli/src/commands/thread.ts +++ b/profile-query-cli/src/commands/thread.ts @@ -33,7 +33,10 @@ function addSamplesOptions(cmd: Command): Command { addGlobalOptions(cmd) .option('--thread ', 'Thread handle (e.g. t-0)') .option('--include-idle', 'Include idle samples in percentages') - .option('--search ', 'Filter by substring') + .option( + '--search ', + 'Keep samples containing this substring in any frame. Comma-separates multiple terms, all must match (AND).' + ) .option('--limit ', 'Limit the number of results shown') ); } diff --git a/profile-query-cli/src/formatters.ts b/profile-query-cli/src/formatters.ts index 473cee7ee0..88d46aef23 100644 --- a/profile-query-cli/src/formatters.ts +++ b/profile-query-cli/src/formatters.ts @@ -514,7 +514,11 @@ function formatCallTreeNode( /** * Helper function to format a call tree. */ -function formatCallTree(tree: CallTreeNode, title: string): string { +function formatCallTree( + tree: CallTreeNode, + title: string, + emptyMessage?: string +): string { const lines: string[] = [`${title} Call Tree:`]; // The root node is virtual, so format its children @@ -525,6 +529,8 @@ function formatCallTree(tree: CallTreeNode, title: string): string { // Root-level nodes don't use tree symbols (they are the starting points) formatCallTreeNode(child, '', false, isLast, 0, lines); } + } else if (emptyMessage) { + lines.push(emptyMessage); } return lines.join('\n'); @@ -556,6 +562,15 @@ export function formatThreadSamplesResult( Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}${filtersNote}`; + if (result.search && result.topFunctionsByTotal.length === 0) { + output += + `No samples matched --search "${result.search}".\n` + + 'Tip: --search keeps samples with a matching frame anywhere in the stack.\n' + + ' Use comma to require multiple terms (all must appear), e.g. --search "foo,bar".\n' + + ' "|" is treated as a literal character, not OR.\n'; + return output; + } + // Top functions by total time output += 'Top Functions (by total time):\n'; output += @@ -657,7 +672,12 @@ export function formatThreadSamplesTopDownResult( Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}${filtersNote}`; // Top-down call tree - output += formatCallTree(result.regularCallTree, 'Top-Down'); + const topDownEmpty = result.search + ? `No samples matched --search "${result.search}".\n` + + 'Tip: use comma to require multiple terms (all must appear), e.g. --search "foo,bar".\n' + + ' "|" is treated as a literal character, not OR.' + : undefined; + output += formatCallTree(result.regularCallTree, 'Top-Down', topDownEmpty); return output; } @@ -690,7 +710,16 @@ Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}${filtersN // Bottom-up call tree (inverted tree shows callers) if (result.invertedCallTree) { - output += formatCallTree(result.invertedCallTree, 'Bottom-Up'); + const bottomUpEmpty = result.search + ? `No samples matched --search "${result.search}".\n` + + 'Tip: use comma to require multiple terms (all must appear), e.g. --search "foo,bar".\n' + + ' "|" is treated as a literal character, not OR.' + : undefined; + output += formatCallTree( + result.invertedCallTree, + 'Bottom-Up', + bottomUpEmpty + ); } else { output += 'Bottom-Up Call Tree:\n (unable to create bottom-up tree)'; } @@ -899,6 +928,11 @@ export function formatThreadFunctionsResult( if (result.filteredFunctionCount === 0) { if (hasFilters) { lines.push('No functions match the specified filters.'); + if (result.filters?.searchString) { + lines.push( + 'Tip: --search matches as a substring of the full function name (including library prefix).' + ); + } } else { lines.push('No functions in this thread.'); } From 80b3b8daac16b3ac67daddc3d037b862870c43ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 13 Apr 2026 19:29:58 +0200 Subject: [PATCH 37/63] Prevent pq zoom unit pitfalls --- profile-query-cli/src/commands/zoom.ts | 4 +++- profile-query-cli/src/formatters.ts | 4 ++++ src/profile-query/index.ts | 8 ++++++++ src/profile-query/types.ts | 1 + 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/profile-query-cli/src/commands/zoom.ts b/profile-query-cli/src/commands/zoom.ts index 157b67a4f5..0e550d99f4 100644 --- a/profile-query-cli/src/commands/zoom.ts +++ b/profile-query-cli/src/commands/zoom.ts @@ -20,7 +20,9 @@ export function registerZoomCommand( addGlobalOptions( zoom .command('push ') - .description('Push a zoom range (e.g. 2.7,3.1 or m-158)') + .description( + 'Push a zoom range (e.g. 2.7,3.1 in seconds, 2700ms,3100ms in milliseconds, 10%,20% as percentage, or m-158 for a marker)' + ) ).action(async (range: string, opts) => { const result = await sendCommand( sessionDir, diff --git a/profile-query-cli/src/formatters.ts b/profile-query-cli/src/formatters.ts index 88d46aef23..f9181bec9e 100644 --- a/profile-query-cli/src/formatters.ts +++ b/profile-query-cli/src/formatters.ts @@ -213,6 +213,10 @@ export function formatViewRangeResult(result: ViewRangeResult): string { } } + if (result.warning) { + output += `\nWarning: ${result.warning}`; + } + return output; } diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 409237ae63..c6bdd87a68 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -348,6 +348,13 @@ export class ProfileQuerier { })(); } + // Warn if the requested range extends outside the profile bounds + let warning: string | undefined; + if (startTimestamp < rootRange.start || endTimestamp > rootRange.end) { + const profileDuration = (rootRange.end - rootRange.start) / 1000; + warning = `Range extends outside the profile duration (${profileDuration.toFixed(3)}s). Did you mean to use milliseconds? Use the "ms" suffix for milliseconds (e.g. 0ms,400ms).`; + } + // Get or create timestamp names for display const startName = this._timestampManager.nameForTimestamp(startTimestamp); const endName = this._timestampManager.nameForTimestamp(endTimestamp); @@ -385,6 +392,7 @@ export class ProfileQuerier { duration, zoomDepth, markerInfo, + warning, }; } diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index a319519fab..1f588795c6 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -185,6 +185,7 @@ export type ViewRangeResult = { threadHandle: string; threadName: string; }; + warning?: string; // Present if the range extends outside the profile duration }; // ===== Thread Commands ===== From 5cee3c18fb5f21d872b1cf1b44621e100b391042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 13 Apr 2026 19:37:50 +0200 Subject: [PATCH 38/63] Add --top-n to marker filtering options --- profile-query-cli/src/commands/thread.ts | 15 +++++- profile-query-cli/src/formatters.ts | 2 +- src/profile-query/formatters/marker-info.ts | 40 +++++++++------ src/profile-query/types.ts | 1 + .../marker-info-formatters.test.ts | 49 +++++++++++++++++++ 5 files changed, 91 insertions(+), 16 deletions(-) diff --git a/profile-query-cli/src/commands/thread.ts b/profile-query-cli/src/commands/thread.ts index 2aa06e6aff..a3e9417bae 100644 --- a/profile-query-cli/src/commands/thread.ts +++ b/profile-query-cli/src/commands/thread.ts @@ -238,6 +238,10 @@ export function registerThreadCommand( '--auto-group', 'Automatically determine grouping based on field variance' ) + .option( + '--top-n ', + 'Number of top markers to include per group in JSON output (default: 5)' + ) ).action(async (opts) => { let markerFilters: MarkerFilterOptions | undefined; @@ -249,7 +253,8 @@ export function registerThreadCommand( opts.hasStack || opts.limit !== undefined || opts.groupBy !== undefined || - opts.autoGroup + opts.autoGroup || + opts.topN !== undefined ) { markerFilters = {}; if (opts.search !== undefined) markerFilters.searchString = opts.search; @@ -286,6 +291,14 @@ export function registerThreadCommand( } markerFilters.limit = limit; } + if (opts.topN !== undefined) { + const topN = parseInt(opts.topN, 10); + if (isNaN(topN) || topN <= 0) { + console.error('Error: --top-n must be a positive integer'); + process.exit(1); + } + markerFilters.topN = topN; + } } const result = await sendCommand( diff --git a/profile-query-cli/src/formatters.ts b/profile-query-cli/src/formatters.ts index f9181bec9e..b330a24852 100644 --- a/profile-query-cli/src/formatters.ts +++ b/profile-query-cli/src/formatters.ts @@ -839,7 +839,7 @@ export function formatThreadMarkersResult( } lines.push( - 'Use --search , --category , --min-duration , --max-duration , --has-stack, --limit , --group-by , or --auto-group to filter/group markers, or m- handles to inspect individual markers.' + 'Use --search , --category , --min-duration , --max-duration , --has-stack, --limit , --group-by , --auto-group, or --top-n to filter/group markers, or m- handles to inspect individual markers.' ); return lines.join('\n'); diff --git a/src/profile-query/formatters/marker-info.ts b/src/profile-query/formatters/marker-info.ts index d118f00e49..fa183d6efc 100644 --- a/src/profile-query/formatters/marker-info.ts +++ b/src/profile-query/formatters/marker-info.ts @@ -482,7 +482,8 @@ function groupMarkers( threadIndexes: Set, markerMap: MarkerMap, getMarkerLabel: (markerIndex: MarkerIndex) => string, - depth: number = 0 + depth: number = 0, + maxTopMarkers: number = 5 ): MarkerGroup[] { if (groupingKeys.length === 0 || markerGroup.length === 0) { return []; @@ -515,7 +516,8 @@ function groupMarkers( items, threadIndexes, markerMap, - getMarkerLabel + getMarkerLabel, + maxTopMarkers ); // Recursively group by remaining keys (limit depth to 3) @@ -528,7 +530,8 @@ function groupMarkers( threadIndexes, markerMap, getMarkerLabel, - depth + 1 + depth + 1, + maxTopMarkers ) : undefined; @@ -560,7 +563,8 @@ function aggregateMarkersByType( markerMap: MarkerMap, getMarkerLabel: (markerIndex: MarkerIndex) => string, categories: CategoryList, - autoGroup: boolean = false + autoGroup: boolean = false, + maxTopMarkers: number = 5 ): MarkerTypeStats[] { // Convert Set to number if needed const groups = new Map< @@ -586,12 +590,13 @@ function aggregateMarkersByType( const durationStats = hasEnd ? computeDurationStats(markerList) : undefined; const rateStats = computeRateStats(markerList); - // Get top 5 markers by duration (or just first 5 for instant markers) + // Get top N markers by duration (or just first N for instant markers) const topMarkers = createTopMarkersArray( markerGroup, threadIndexes, markerMap, - getMarkerLabel + getMarkerLabel, + maxTopMarkers ); // Apply auto-grouping if enabled @@ -608,7 +613,8 @@ function aggregateMarkersByType( threadIndexes, markerMap, getMarkerLabel, - 1 + 1, + maxTopMarkers ); subGroupKey = fieldInfo.field; } @@ -751,7 +757,8 @@ export function formatThreadMarkers( return lines.join('\n'); } - const { groupBy, autoGroup } = filterOptions; + const { groupBy, autoGroup, topN } = filterOptions; + const maxTopMarkers = topN ?? 5; // Handle custom grouping if groupBy is specified if (groupBy) { @@ -771,7 +778,8 @@ export function formatThreadMarkers( threadIndexes, markerMap, getMarkerLabel, - 0 + 0, + maxTopMarkers ); // Format and display hierarchical groups @@ -785,7 +793,8 @@ export function formatThreadMarkers( markerMap, getMarkerLabel, categories, - autoGroup || false + autoGroup || false, + maxTopMarkers ); // Show top 15 marker names @@ -869,7 +878,7 @@ export function formatThreadMarkers( } lines.push( - 'Use --search , --category , --min-duration , --max-duration , --has-stack, --limit , --group-by , or --auto-group to filter/group markers, or m- handles to inspect individual markers.' + 'Use --search , --category , --min-duration , --max-duration , --has-stack, --limit , --group-by , --auto-group, or --top-n to filter/group markers, or m- handles to inspect individual markers.' ); return lines.join('\n'); @@ -941,7 +950,8 @@ export function collectThreadMarkers( const displayThreadHandle = threadHandle ?? threadMap.handleForThreadIndexes(threadIndexes); - const { groupBy, autoGroup } = filterOptions; + const { groupBy, autoGroup, topN } = filterOptions; + const maxTopMarkers = topN ?? 5; // Handle custom grouping if groupBy is specified let customGroups: MarkerGroupData[] | undefined; @@ -962,7 +972,8 @@ export function collectThreadMarkers( threadIndexes, markerMap, getMarkerLabel, - 0 + 0, + maxTopMarkers ); // Add markerIndex to topMarkers in groups @@ -977,7 +988,8 @@ export function collectThreadMarkers( markerMap, getMarkerLabel, categories, - autoGroup || false + autoGroup || false, + maxTopMarkers ); // Convert typeStats to include markerIndex diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 1f588795c6..f34111566b 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -34,6 +34,7 @@ export type MarkerFilterOptions = { limit?: number; // Limit the number of markers in aggregation (not output lines) groupBy?: string; // Grouping strategy (e.g., "type,name" or "type,field:eventType") autoGroup?: boolean; // Automatically determine grouping based on field variance + topN?: number; // Number of top markers to include per group in JSON output (default: 5) }; export type FunctionFilterOptions = { diff --git a/src/test/unit/profile-query/marker-info-formatters.test.ts b/src/test/unit/profile-query/marker-info-formatters.test.ts index d88f95a6ab..5bec528b17 100644 --- a/src/test/unit/profile-query/marker-info-formatters.test.ts +++ b/src/test/unit/profile-query/marker-info-formatters.test.ts @@ -5,6 +5,7 @@ import { collectMarkerInfo, collectMarkerStack, + collectThreadMarkers, formatMarkerInfo, formatMarkerStackFull, formatThreadMarkers, @@ -280,6 +281,54 @@ describe('collectMarkerInfo', function () { }); }); +describe('collectThreadMarkers topN option', function () { + it('defaults to 5 top markers per group', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['Phase', 0, 1, { type: 'tracing', interval: 'start' }], + ['Phase', 1, 2, { type: 'tracing', interval: 'start' }], + ['Phase', 2, 3, { type: 'tracing', interval: 'start' }], + ['Phase', 3, 4, { type: 'tracing', interval: 'start' }], + ['Phase', 4, 5, { type: 'tracing', interval: 'start' }], + ['Phase', 5, 6, { type: 'tracing', interval: 'start' }], + ['Phase', 6, 7, { type: 'tracing', interval: 'start' }], + ]); + + const result = collectThreadMarkers(store, threadMap, markerMap); + + const phaseStats = result.byType.find((s) => s.markerName === 'Phase'); + expect(phaseStats).toBeDefined(); + expect(phaseStats!.count).toBe(7); + expect(phaseStats!.topMarkers).toHaveLength(5); + }); + + it('respects topN option', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['Phase', 0, 1, { type: 'tracing', interval: 'start' }], + ['Phase', 1, 2, { type: 'tracing', interval: 'start' }], + ['Phase', 2, 3, { type: 'tracing', interval: 'start' }], + ['Phase', 3, 4, { type: 'tracing', interval: 'start' }], + ['Phase', 4, 5, { type: 'tracing', interval: 'start' }], + ['Phase', 5, 6, { type: 'tracing', interval: 'start' }], + ['Phase', 6, 7, { type: 'tracing', interval: 'start' }], + ]); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { + topN: 10, + } + ); + + const phaseStats = result.byType.find((s) => s.markerName === 'Phase'); + expect(phaseStats).toBeDefined(); + expect(phaseStats!.count).toBe(7); + expect(phaseStats!.topMarkers).toHaveLength(7); + }); +}); + describe('collectMarkerStack and formatMarkerStackFull', function () { describe('for a marker without a cause', function () { it('collectMarkerStack returns null stack', function () { From ec6873475764348f3f524e100cfa1c4cadf83b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 13 Apr 2026 19:54:48 +0200 Subject: [PATCH 39/63] Mention the pq zoom option in the command output --- profile-query-cli/src/formatters.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/profile-query-cli/src/formatters.ts b/profile-query-cli/src/formatters.ts index b330a24852..c618b31edb 100644 --- a/profile-query-cli/src/formatters.ts +++ b/profile-query-cli/src/formatters.ts @@ -839,7 +839,7 @@ export function formatThreadMarkersResult( } lines.push( - 'Use --search , --category , --min-duration , --max-duration , --has-stack, --limit , --group-by , --auto-group, or --top-n to filter/group markers, or m- handles to inspect individual markers.' + 'Use --search , --category , --min-duration , --max-duration , --has-stack, --limit , --group-by , --auto-group, or --top-n to filter/group markers, or m- handles to inspect individual markers or zoom into their time range (pq zoom push m-).' ); return lines.join('\n'); From 07f4ad43e300023843fc2d5d0062a04f79b36f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Tue, 14 Apr 2026 16:18:39 +0200 Subject: [PATCH 40/63] Rename pq to profiler-cli --- .gitignore | 2 +- profile-query-cli/.npmignore | 6 +- profile-query-cli/README.md | 96 +++---- profile-query-cli/guide.txt | 266 +++++++++--------- profile-query-cli/package.json | 10 +- profile-query-cli/schemas.txt | 18 +- profile-query-cli/src/client.ts | 10 +- profile-query-cli/src/commands/filter.ts | 4 +- profile-query-cli/src/commands/function.ts | 2 +- profile-query-cli/src/commands/marker.ts | 2 +- profile-query-cli/src/commands/profile.ts | 2 +- profile-query-cli/src/commands/session.ts | 4 +- profile-query-cli/src/commands/shared.ts | 6 +- profile-query-cli/src/commands/thread.ts | 2 +- profile-query-cli/src/commands/zoom.ts | 2 +- profile-query-cli/src/daemon.ts | 2 +- profile-query-cli/src/formatters.ts | 10 +- profile-query-cli/src/index.ts | 56 ++-- profile-query-cli/src/output.ts | 2 +- profile-query-cli/src/protocol.ts | 2 +- profile-query-cli/src/session.ts | 6 +- .../src/test/integration/basic.test.ts | 77 +++-- .../test/integration/daemon-startup.test.ts | 20 +- .../src/test/integration/sessions.test.ts | 84 +++--- .../src/test/integration/utils.ts | 38 +-- .../src/test/unit/client.test.ts | 4 +- .../src/test/unit/daemon.test.ts | 4 +- .../src/test/unit/session.test.ts | 22 +- profile-query-cli/src/utils/parse.ts | 4 +- ...study-2.md => profiler-cli-case-study-2.md | 104 +++---- ...ase-study.md => profiler-cli-case-study.md | 16 +- ...iler-cli-filters-and-bookmarks-proposal.md | 216 +++++++------- ....md => profiler-cli-marker-support-plan.md | 94 +++---- pq-todo.md => profiler-cli-todo.md | 130 ++++----- scripts/build-profile-query-cli.mjs | 4 +- src/profile-logic/transforms.ts | 12 +- src/profile-query/README.md | 2 +- src/profile-query/filter-stack.ts | 4 +- src/profile-query/formatters/marker-info.ts | 2 +- src/profile-query/types.ts | 4 +- .../profile-query/profile-querier.test.ts | 2 +- src/types/transforms.ts | 2 +- 42 files changed, 679 insertions(+), 676 deletions(-) rename pq-case-study-2.md => profiler-cli-case-study-2.md (81%) rename pq-case-study.md => profiler-cli-case-study.md (96%) rename pq-filters-and-bookmarks-proposal.md => profiler-cli-filters-and-bookmarks-proposal.md (69%) rename marker-support-plan.md => profiler-cli-marker-support-plan.md (86%) rename pq-todo.md => profiler-cli-todo.md (65%) diff --git a/.gitignore b/.gitignore index 82ebba8022..ee48a80fe9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,4 @@ webpack.local-config.js *.orig *.rej .idea/ -.pq-dev/ +.profiler-cli-dev/ diff --git a/profile-query-cli/.npmignore b/profile-query-cli/.npmignore index 0c37eee2a6..8bc7c4fe8e 100644 --- a/profile-query-cli/.npmignore +++ b/profile-query-cli/.npmignore @@ -1,10 +1,10 @@ -# Source files - only publish the built dist/pq.js +# Source files - only publish the built dist/profiler-cli.js *.ts webpack.config.js -# Build artifacts (except dist/pq.js which is in "files") +# Build artifacts (except dist/profiler-cli.js which is in "files") dist/* -!dist/pq.js +!dist/profiler-cli.js # Development files node_modules/ diff --git a/profile-query-cli/README.md b/profile-query-cli/README.md index a019752548..0efd8083cc 100644 --- a/profile-query-cli/README.md +++ b/profile-query-cli/README.md @@ -1,4 +1,4 @@ -# Profile Query CLI (`pq`) +# Profile Query CLI (`profiler-cli`) A command-line interface for querying Firefox Profiler profiles with persistent daemon sessions. @@ -11,57 +11,57 @@ A command-line interface for querying Firefox Profiler profiles with persistent **IPC:** Unix domain sockets (named pipes on Windows) with line-delimited JSON messages -**Session storage:** `~/.pq/` (or `$PQ_SESSION_DIR` for development) +**Session storage:** `~/.profiler-cli/` (or `$PROFILER_CLI_SESSION_DIR` for development) `ProfileQuerier` lives in `src/profile-query/` in the main profiler repo and is shared with the web app. The CLI daemon is just an IPC wrapper around it — query logic belongs in `src/profile-query/`, not in `daemon.ts`. ## Commands -**Note:** On machines with `pq` in PATH, you can use `pq` instead of `./dist/pq.js` for shorter commands. +**Note:** On machines with `profiler-cli` in PATH, you can use `profiler-cli` instead of `./dist/profiler-cli.js` for shorter commands. ```bash # Build the CLI yarn build-profile-query-cli # Basic usage -pq load # Start daemon and load profile (PATH can be a file or http/https URL) -pq profile info # Print profile summary [--all] [--search ] -pq thread info # Print detailed thread information -pq thread select # Select a thread (e.g., t-0, t-1) -pq thread samples # Show hot functions list for current thread -pq thread samples-top-down # Show top-down call tree (where CPU time is spent) -pq thread samples-bottom-up # Show bottom-up call tree (what calls hot functions) -pq thread markers # List markers with aggregated statistics -pq thread functions # List all functions with CPU percentages -pq marker info # Show detailed marker information (e.g., m-1234) -pq marker stack # Show full stack trace for a marker -pq function expand # Show full untruncated function name (e.g., f-123) -pq function info # Show detailed function information -pq zoom push # Push a zoom range (e.g., 2.7,3.1 or ts-g,ts-G or m-158) -pq zoom pop # Pop the most recent zoom range -pq zoom clear # Clear all zoom ranges (return to full profile) -pq filter push # Push a sticky sample filter (see filter flags below) -pq filter pop [N] # Pop the last N filters (default: 1) -pq filter list # List active filters for current thread -pq filter clear # Remove all filters for current thread -pq status # Show session status (selected thread, zoom ranges, filters) -pq stop # Stop current daemon -pq stop # Stop a specific session -pq stop --all # Stop all sessions -pq session list # List all running daemon sessions (* marks current) -pq session use # Switch the current session +profiler-cli load # Start daemon and load profile (PATH can be a file or http/https URL) +profiler-cli profile info # Print profile summary [--all] [--search ] +profiler-cli thread info # Print detailed thread information +profiler-cli thread select # Select a thread (e.g., t-0, t-1) +profiler-cli thread samples # Show hot functions list for current thread +profiler-cli thread samples-top-down # Show top-down call tree (where CPU time is spent) +profiler-cli thread samples-bottom-up # Show bottom-up call tree (what calls hot functions) +profiler-cli thread markers # List markers with aggregated statistics +profiler-cli thread functions # List all functions with CPU percentages +profiler-cli marker info # Show detailed marker information (e.g., m-1234) +profiler-cli marker stack # Show full stack trace for a marker +profiler-cli function expand # Show full untruncated function name (e.g., f-123) +profiler-cli function info # Show detailed function information +profiler-cli zoom push # Push a zoom range (e.g., 2.7,3.1 or ts-g,ts-G or m-158) +profiler-cli zoom pop # Pop the most recent zoom range +profiler-cli zoom clear # Clear all zoom ranges (return to full profile) +profiler-cli filter push # Push a sticky sample filter (see filter flags below) +profiler-cli filter pop [N] # Pop the last N filters (default: 1) +profiler-cli filter list # List active filters for current thread +profiler-cli filter clear # Remove all filters for current thread +profiler-cli status # Show session status (selected thread, zoom ranges, filters) +profiler-cli stop # Stop current daemon +profiler-cli stop # Stop a specific session +profiler-cli stop --all # Stop all sessions +profiler-cli session list # List all running daemon sessions (* marks current) +profiler-cli session use # Switch the current session # Multiple sessions -pq load --session -pq profile info --session +profiler-cli load --session +profiler-cli profile info --session # Thread selection -pq thread select t-93 # Select thread t-93 -pq thread samples # View samples for selected thread -pq thread info --thread t-0 # View info for specific thread without selecting +profiler-cli thread select t-93 # Select thread t-93 +profiler-cli thread samples # View samples for selected thread +profiler-cli thread info --thread t-0 # View info for specific thread without selecting ``` -Run `pq guide` for a detailed usage guide with patterns and tips. Run `pq --help` for the full options reference. +Run `profiler-cli guide` for a detailed usage guide with patterns and tips. Run `profiler-cli --help` for the full options reference. ### Common options @@ -119,8 +119,8 @@ This package uses a **bundled distribution approach**: - **Source code**: Lives in `profile-query-cli/src/` within the firefox-devtools/profiler monorepo - **Dependencies**: Defined in the root `package.json` (react, redux, protobufjs, etc.) -- **Build process**: The CLI build writes a single ~640KB executable to `profile-query-cli/dist/pq.js` (~187KB gzipped) with zero runtime dependencies -- **Published artifact**: `profile-query-cli/dist/pq.js` is published to npm as `@firefox-profiler/pq` +- **Build process**: The CLI build writes a single ~640KB executable to `profile-query-cli/dist/profiler-cli.js` (~187KB gzipped) with zero runtime dependencies +- **Published artifact**: `profile-query-cli/dist/profiler-cli.js` is published to npm as `@firefox-profiler/profiler-cli` - **Package.json**: Contains only npm metadata - it does NOT list dependencies since they're pre-bundled This means: @@ -140,27 +140,27 @@ npm publish ## Session Management -**Session directory:** `~/.pq/` or `$PQ_SESSION_DIR` +**Session directory:** `~/.profiler-cli/` or `$PROFILER_CLI_SESSION_DIR` **Files per session:** ``` -~/.pq/ +~/.profiler-cli/ ├── current # Symlink to current session socket ├── .sock # Unix domain socket for IPC (Unix only) ├── .json # Session metadata (PID, profile path, timestamps) └── .log # Daemon logs (kept for debugging) ``` -On Windows, IPC uses a named pipe (`\\.\pipe\pq-`) instead of a `.sock` file. +On Windows, IPC uses a named pipe (`\\.\pipe\profiler-cli-`) instead of a `.sock` file. **Session metadata example:** ```json { "id": "abc123xyz", - "socketPath": "/Users/user/.pq/abc123xyz.sock", // or \\.\pipe\pq-abc123xyz on Windows - "logPath": "/Users/user/.pq/abc123xyz.log", + "socketPath": "/Users/user/.profiler-cli/abc123xyz.sock", // or \\.\pipe\profiler-cli-abc123xyz on Windows + "logPath": "/Users/user/.profiler-cli/abc123xyz.log", "pid": 12345, "profilePath": "/path/to/profile.json", "createdAt": "2025-10-31T10:00:00.000Z", @@ -173,16 +173,16 @@ On Windows, IPC uses a named pipe (`\\.\pipe\pq-`) instead of a `.so **Environment variable isolation:** ```bash -export PQ_SESSION_DIR="./.pq-dev" # Use local directory instead of ~/.pq -pq load profile.json # or: ./dist/pq.js load profile.json +export PROFILER_CLI_SESSION_DIR="./.profiler-cli-dev" # Use local directory instead of ~/.profiler-cli +profiler-cli load profile.json # or: ./dist/profiler-cli.js load profile.json ``` -All test scripts automatically set `PQ_SESSION_DIR="./.pq-dev"` to avoid polluting global state. +All test scripts automatically set `PROFILER_CLI_SESSION_DIR="./.profiler-cli-dev"` to avoid polluting global state. **Build:** ```bash -yarn build-profile-query-cli # Creates ./dist/pq.js +yarn build-profile-query-cli # Creates ./dist/profiler-cli.js ``` **Unit tests:** @@ -264,11 +264,11 @@ These commands are parsed and routed but throw "unimplemented" in the daemon: - The banner also sets `globalThis.self = globalThis` for browser-oriented shared code - `__BUILD_HASH__` is injected at build time - `gecko-profiler-demangle` is left external to keep the CLI lean -- Postbuild: `chmod +x dist/pq.js` +- Postbuild: `chmod +x dist/profiler-cli.js` ## Adding New Commands -To add a new command, you need to modify **5 files** (client.ts doesn't need changes as it generically forwards commands). The example below adds a hypothetical `pq allocation info` command. +To add a new command, you need to modify **5 files** (client.ts doesn't need changes as it generically forwards commands). The example below adds a hypothetical `profiler-cli allocation info` command. ### Step 1: Define types in `protocol.ts` diff --git a/profile-query-cli/guide.txt b/profile-query-cli/guide.txt index 205d727440..9912e3acee 100644 --- a/profile-query-cli/guide.txt +++ b/profile-query-cli/guide.txt @@ -1,99 +1,99 @@ -pq: Usage Guide +profiler-cli: Usage Guide -pq (Profile Querier) queries Firefox performance profiles loaded into a persistent +profiler-cli (Profile Querier) queries Firefox performance profiles loaded into a persistent daemon. Load a profile once, then query it interactively without reloading. QUICK START - pq load profile.json.gz - pq profile info Find threads; note handles (e.g. t-0, t-1) - pq thread select t-0 - pq thread samples Hot functions by self time - pq thread samples-top-down Full top-down call tree + profiler-cli load profile.json.gz + profiler-cli profile info Find threads; note handles (e.g. t-0, t-1) + profiler-cli thread select t-0 + profiler-cli thread samples Hot functions by self time + profiler-cli thread samples-top-down Full top-down call tree The rest of this guide covers filtering, zooming, markers, and advanced analysis. SESSION MODEL - pq uses a daemon/session model: - 1. Load a profile with "pq load" -- starts a background daemon + profiler-cli uses a daemon/session model: + 1. Load a profile with "profiler-cli load" -- starts a background daemon 2. Query the loaded profile with any other commands - 3. Stop with "pq stop" when done + 3. Stop with "profiler-cli stop" when done The daemon persists your selected thread, zoom ranges, and filter stacks across queries. For scripting or concurrent work, prefer an explicit session ID so each workflow gets isolated state: - pq load profile.json.gz --session run-a - pq profile info --session run-a - pq thread select t-0 --session run-a - pq thread samples --session run-a + profiler-cli load profile.json.gz --session run-a + profiler-cli profile info --session run-a + profiler-cli thread select t-0 --session run-a + profiler-cli thread samples --session run-a CORE WORKFLOW Step 1: Load a profile - pq load profile.json.gz - pq load profile.json.gz --session run-a Recommended for scripting or concurrent work + profiler-cli load profile.json.gz + profiler-cli load profile.json.gz --session run-a Recommended for scripting or concurrent work Step 2: Explore the profile - pq profile info Overview: processes, threads, time range, CPU activity - pq profile info --all Show all processes and threads (not just top 5) - pq profile info --search GeckoMain Filter by process/thread name, pid, or tid + profiler-cli profile info Overview: processes, threads, time range, CPU activity + profiler-cli profile info --all Show all processes and threads (not just top 5) + profiler-cli profile info --search GeckoMain Filter by process/thread name, pid, or tid Step 3: Select a thread to analyze - pq thread select t-0 Select a specific thread (persists for future commands) + profiler-cli thread select t-0 Select a specific thread (persists for future commands) Step 4: Analyze CPU usage - pq thread samples Hot functions list (self time, sorted by impact) - pq thread samples-top-down Top-down call tree (where does CPU time originate) - pq thread samples-bottom-up Bottom-up call tree (what calls the hot functions) - pq thread functions All functions with self/total CPU percentages + profiler-cli thread samples Hot functions list (self time, sorted by impact) + profiler-cli thread samples-top-down Top-down call tree (where does CPU time originate) + profiler-cli thread samples-bottom-up Bottom-up call tree (what calls the hot functions) + profiler-cli thread functions All functions with self/total CPU percentages All samples commands exclude idle by default so percentages reflect active CPU time. Use --include-idle to include idle samples (e.g. to see what fraction of wall time is idle). Use --search to focus the call tree on paths containing a specific function: - pq thread samples-top-down --search GC - pq thread samples-bottom-up --search "JS::Compile" - pq thread samples --search malloc + profiler-cli thread samples-top-down --search GC + profiler-cli thread samples-bottom-up --search "JS::Compile" + profiler-cli thread samples --search malloc Search syntax for samples commands: - Matches any frame in the call stack (case-insensitive substring) - Comma joins multiple terms with AND: both must appear somewhere in the stack - pq thread samples-top-down --search "GC,malloc" paths through both GC and malloc + profiler-cli thread samples-top-down --search "GC,malloc" paths through both GC and malloc - "|" is a literal character, not OR — it will match nothing unless a function name actually contains "|" - To see results matching either of two terms, run two separate searches Step 5: Analyze markers (browser events, timers, etc.) - pq thread markers All markers with aggregated stats - pq thread markers --category Layout Filter by category - pq thread markers --search DOMEvent Filter by name substring - pq thread markers --min-duration 10 Only markers >= 10ms - pq thread markers --has-stack Only markers with stack traces + profiler-cli thread markers All markers with aggregated stats + profiler-cli thread markers --category Layout Filter by category + profiler-cli thread markers --search DOMEvent Filter by name substring + profiler-cli thread markers --min-duration 10 Only markers >= 10ms + profiler-cli thread markers --has-stack Only markers with stack traces Step 6: Drill into specifics - pq marker info m-1234 Full details for a marker (from handles in marker list) - pq marker stack m-1234 Full stack trace at the time of a marker - pq function info f-12 Function details (source location, library) - pq function expand f-12 Show full untruncated function name + profiler-cli marker info m-1234 Full details for a marker (from handles in marker list) + profiler-cli marker stack m-1234 Full stack trace at the time of a marker + profiler-cli function info f-12 Function details (source location, library) + profiler-cli function expand f-12 Show full untruncated function name Step 7: Filter to focus the analysis (see FILTERS section below) Ephemeral -- applies only to that one command, sticky state unchanged: - pq thread samples --excludes-function f-184 Ignore one noisy function - pq thread samples --root-at f-500 Show only work inside f-500 - pq thread samples --during-marker --search Paint Only samples during Paint markers + profiler-cli thread samples --excludes-function f-184 Ignore one noisy function + profiler-cli thread samples --root-at f-500 Show only work inside f-500 + profiler-cli thread samples --during-marker --search Paint Only samples during Paint markers Sticky -- persists across all subsequent commands until popped: - pq filter push --merge f-142,f-143 Merge allocators out of stacks - pq filter push --includes-function f-500 Keep only samples containing f-500 - pq thread samples Uses sticky filters automatically - pq filter pop Remove last filter - pq filter clear Remove all filters + profiler-cli filter push --merge f-142,f-143 Merge allocators out of stacks + profiler-cli filter push --includes-function f-500 Keep only samples containing f-500 + profiler-cli thread samples Uses sticky filters automatically + profiler-cli filter pop Remove last filter + profiler-cli filter clear Remove all filters SESSION STATE @@ -104,7 +104,7 @@ SESSION STATE zoom_stack set by zoom push/pop/clear; restricts all queries to a time window filter_stack[t] set by filter push/pop/clear; per-thread, independent stacks - Use "pq status" to inspect all session state at any time. + Use "profiler-cli status" to inspect all session state at any time. HANDLE SYSTEM @@ -135,14 +135,14 @@ ZOOM: FOCUS ON A TIME RANGE Zoom restricts all subsequent queries to a specific time window. Useful for drilling into a specific jank period, page load phase, or marker duration. - pq zoom push 2.7,3.1 Zoom to 2.7s-3.1s (relative to profile start) - pq zoom push m-158 Zoom to the time range of marker m-158 - pq zoom push ts-6,ts-12 Zoom to the range between two named timestamps - pq zoom pop Undo the last zoom - pq zoom clear Return to full profile view + profiler-cli zoom push 2.7,3.1 Zoom to 2.7s-3.1s (relative to profile start) + profiler-cli zoom push m-158 Zoom to the time range of marker m-158 + profiler-cli zoom push ts-6,ts-12 Zoom to the range between two named timestamps + profiler-cli zoom pop Undo the last zoom + profiler-cli zoom clear Return to full profile view After zooming, all thread/marker/sample commands automatically apply the filter. - Use "pq status" to confirm the active zoom stack (along with thread and filters). + Use "profiler-cli status" to confirm the active zoom stack (along with thread and filters). FILTERS @@ -154,26 +154,26 @@ FILTERS only to that invocation; the sticky filter stack is unchanged. Multiple flags may be combined on one command and are applied left to right. - pq thread samples --excludes-function f-184 - pq thread samples --merge f-142,f-143 --root-at f-500 --limit 30 - pq thread functions --includes-function f-500 + profiler-cli thread samples --excludes-function f-184 + profiler-cli thread samples --merge f-142,f-143 --root-at f-500 --limit 30 + profiler-cli thread functions --includes-function f-500 STICKY (persists across commands) - Use "pq filter push" to add a filter to the current thread's stack. + Use "profiler-cli filter push" to add a filter to the current thread's stack. Every subsequent analysis command sees all pushed filters automatically. Filters are per-thread: each thread has its own independent filter stack. Exactly one filter flag per "filter push" command. - pq filter push --merge f-142,f-143 # add filter 1 - pq filter push --includes-function f-500 # add filter 2 (AND: both must pass) - pq thread samples # both filters active - pq thread functions # both filters active - pq filter pop # remove filter 2 - pq filter pop 2 # remove last 2 filters - pq filter list # show active filters in push order - pq filter clear # remove all filters + profiler-cli filter push --merge f-142,f-143 # add filter 1 + profiler-cli filter push --includes-function f-500 # add filter 2 (AND: both must pass) + profiler-cli thread samples # both filters active + profiler-cli thread functions # both filters active + profiler-cli filter pop # remove filter 2 + profiler-cli filter pop 2 # remove last 2 filters + profiler-cli filter list # show active filters in push order + profiler-cli filter clear # remove all filters - Use "pq status" to inspect the full session state: selected thread, zoom stack, and + Use "profiler-cli status" to inspect the full session state: selected thread, zoom stack, and all per-thread filters. Sticky + ephemeral compose: sticky filters apply first (already in the transform @@ -204,9 +204,9 @@ FILTERS Push order matters: each filter sees the stack as left by the prior filter. Example -- only samples containing f-500, during Paint markers, after merging noise: - pq filter push --merge f-142,f-143 - pq filter push --includes-function f-500 - pq filter push --during-marker --search Paint + profiler-cli filter push --merge f-142,f-143 + profiler-cli filter push --includes-function f-500 + profiler-cli filter push --during-marker --search Paint JSON OUTPUT @@ -214,68 +214,68 @@ JSON OUTPUT Add --json to any command to get structured JSON output, suitable for piping to jq or processing programmatically. - pq thread samples --json | jq '.topFunctionsBySelf[0]' - pq thread markers --json | jq '[.byType[] | select(.durationStats.max > 50)]' - pq profile info --json | jq '.processes[].threads[] | {handle: .threadHandle, name}' + profiler-cli thread samples --json | jq '.topFunctionsBySelf[0]' + profiler-cli thread markers --json | jq '[.byType[] | select(.durationStats.max > 50)]' + profiler-cli profile info --json | jq '.processes[].threads[] | {handle: .threadHandle, name}' - Run "pq schemas" for the full JSON schema reference. + Run "profiler-cli schemas" for the full JSON schema reference. COMMON ANALYSIS PATTERNS Find what's consuming CPU: - pq thread info Pick the main thread (usually GeckoMain) - pq thread select t-0 - pq thread samples Top hot functions by self time (active only) - pq thread samples-top-down See the full call tree - pq thread samples-top-down --search GC Focus tree on paths through "GC" + profiler-cli thread info Pick the main thread (usually GeckoMain) + profiler-cli thread select t-0 + profiler-cli thread samples Top hot functions by self time (active only) + profiler-cli thread samples-top-down See the full call tree + profiler-cli thread samples-top-down --search GC Focus tree on paths through "GC" Investigate a jank/slow period: - pq thread markers --min-duration 50 Find long-running markers (>50ms = jank) - pq zoom push m-158 Zoom into that marker's time range - pq thread samples What was executing during that period? - pq zoom pop Back to full profile + profiler-cli thread markers --min-duration 50 Find long-running markers (>50ms = jank) + profiler-cli zoom push m-158 Zoom into that marker's time range + profiler-cli thread samples What was executing during that period? + profiler-cli zoom pop Back to full profile Eliminate allocator noise from a call tree: - pq thread functions --search malloc Find allocator function handles (e.g. f-142) - pq thread samples --merge f-142 Try ephemerally first - pq filter push --merge f-142,f-143 Make it sticky for all subsequent commands - pq thread samples Clean call tree, filters persist - pq filter clear + profiler-cli thread functions --search malloc Find allocator function handles (e.g. f-142) + profiler-cli thread samples --merge f-142 Try ephemerally first + profiler-cli filter push --merge f-142,f-143 Make it sticky for all subsequent commands + profiler-cli thread samples Clean call tree, filters persist + profiler-cli filter clear Focus on work inside a specific function: - pq thread functions --search PresentImpl Note the handle (e.g. f-500) - pq thread samples --root-at f-500 Ephemeral: subtree rooted at f-500 - pq filter push --includes-function f-500 Sticky: only samples containing f-500 - pq filter push --root-at f-500 Sticky: re-root at f-500 - pq thread samples-top-down Call tree within f-500 only + profiler-cli thread functions --search PresentImpl Note the handle (e.g. f-500) + profiler-cli thread samples --root-at f-500 Ephemeral: subtree rooted at f-500 + profiler-cli filter push --includes-function f-500 Sticky: only samples containing f-500 + profiler-cli filter push --root-at f-500 Sticky: re-root at f-500 + profiler-cli thread samples-top-down Call tree within f-500 only Correlate CPU with a specific event type: - pq thread markers --search Paint Check Paint marker frequency/duration - pq filter push --during-marker --search Paint - pq thread samples What runs during Paint? - pq thread functions Which functions are active during Paint? - pq filter clear + profiler-cli thread markers --search Paint Check Paint marker frequency/duration + profiler-cli filter push --during-marker --search Paint + profiler-cli thread samples What runs during Paint? + profiler-cli thread functions Which functions are active during Paint? + profiler-cli filter clear Find slow layout or script execution: - pq thread markers --category Layout --min-duration 5 - pq thread markers --category JavaScript --min-duration 10 - pq thread markers --search Reflow --min-duration 5 + profiler-cli thread markers --category Layout --min-duration 5 + profiler-cli thread markers --category JavaScript --min-duration 10 + profiler-cli thread markers --search Reflow --min-duration 5 Deep dive on a specific function: - pq thread samples Find hot function, note its handle (f-12) - pq function info f-12 See callers, callees, source location - pq function expand f-12 See full name if truncated + profiler-cli thread samples Find hot function, note its handle (f-12) + profiler-cli function info f-12 See callers, callees, source location + profiler-cli function expand f-12 See full name if truncated Group markers by event type: - pq thread markers --search DOMEvent --group-by field:eventType - pq thread markers --auto-group - pq thread markers --group-by type,name + profiler-cli thread markers --search DOMEvent --group-by field:eventType + profiler-cli thread markers --auto-group + profiler-cli thread markers --group-by type,name Analyze network activity: - pq thread markers --category Network - pq thread markers --category Network --min-duration 100 - pq thread markers --search "http" --category Network + profiler-cli thread markers --category Network + profiler-cli thread markers --category Network --min-duration 100 + profiler-cli thread markers --search "http" --category Network UNDERSTANDING THE OUTPUT @@ -324,18 +324,18 @@ TIPS with "filter push" - "filter push --during-marker --search X" is powerful for correlating CPU work with specific event types - - Check "pq status" after any state changes to confirm selected thread, active + - Check "profiler-cli status" after any state changes to confirm selected thread, active zoom, and active filters before running analysis SCRIPTING - When using pq in scripts or pipelines: + When using profiler-cli in scripts or pipelines: Always use a named session for isolation: - pq load profile.json.gz --session my-analysis - pq profile info --session my-analysis - pq thread select t-0 --session my-analysis + profiler-cli load profile.json.gz --session my-analysis + profiler-cli profile info --session my-analysis + profiler-cli thread select t-0 --session my-analysis Always use --json for reliable output parsing. Plain text output is for human reading and may change; the JSON schema is stable. @@ -346,54 +346,54 @@ SCRIPTING Extracting handles with jq: # Find the GeckoMain thread handle - pq profile info --json | \ + profiler-cli profile info --json | \ jq -r '.processes[].threads[] | select(.name | test("GeckoMain")) | .threadHandle' # Get the top self-time function handle - pq thread samples --json | jq -r '.topFunctionsBySelf[0].functionHandle' + profiler-cli thread samples --json | jq -r '.topFunctionsBySelf[0].functionHandle' # List marker handles for markers longer than 50ms - pq thread markers --json | \ + profiler-cli thread markers --json | \ jq -r '[.byType[].topMarkers[] | select(.duration > 50) | .handle][]' SESSION MANAGEMENT - pq session list List all running daemon sessions (* marks current) - pq session use Switch the current session - pq stop Stop the current session - pq stop Stop a specific session - pq stop --all Stop all sessions - pq load profile.json.gz --session my-session Named session - pq thread info --session my-session Query a specific session + profiler-cli session list List all running daemon sessions (* marks current) + profiler-cli session use Switch the current session + profiler-cli stop Stop the current session + profiler-cli stop Stop a specific session + profiler-cli stop --all Stop all sessions + profiler-cli load profile.json.gz --session my-session Named session + profiler-cli thread info --session my-session Query a specific session ERROR HANDLING "No running session" - Run "pq load " first. If using a named session, ensure --session matches. - Run "pq session list" to see what sessions are currently active. + Run "profiler-cli load " first. If using a named session, ensure --session matches. + Run "profiler-cli session list" to see what sessions are currently active. "Thread not found" - The handle does not exist in this profile. Run "pq profile info" to see valid - thread handles, then re-run "pq thread select" with a handle from that list. + The handle does not exist in this profile. Run "profiler-cli profile info" to see valid + thread handles, then re-run "profiler-cli thread select" with a handle from that list. "No thread selected" - Run "pq thread select " before querying thread data. Use "pq profile info" + Run "profiler-cli thread select " before querying thread data. Use "profiler-cli profile info" first to get valid handles. "Marker not found" Marker handles (m-N) are session-scoped. If the daemon was restarted, re-run - "pq thread markers" to get fresh handles for the new session. + "profiler-cli thread markers" to get fresh handles for the new session. "Function not found" Function handles (f-N) are stable across sessions for the same profile, but only valid after the profile is loaded. Verify the correct profile is loaded with - "pq status" (check the profile path). + "profiler-cli status" (check the profile path). "Session not found" / "No such session" - The session has exited or was stopped. Run "pq session list" to see active sessions. - Run "pq load --session " to start a new session with that ID. + The session has exited or was stopped. Run "profiler-cli session list" to see active sessions. + Run "profiler-cli load --session " to start a new session with that ID. "Profile load failed" Check that the path is correct and the file is a valid supported profile format. - The daemon log at ~/.pq/.log contains the full error detail. + The daemon log at ~/.profiler-cli/.log contains the full error detail. diff --git a/profile-query-cli/package.json b/profile-query-cli/package.json index aa02a2c47d..6b59e0f128 100644 --- a/profile-query-cli/package.json +++ b/profile-query-cli/package.json @@ -1,14 +1,14 @@ { - "_comment": "This is a distribution package for a pre-bundled CLI artifact. Build dependencies are defined in the root package.json. Run 'yarn build-profile-query-cli' from the repository root to build dist/pq.js.", - "name": "@firefox-profiler/pq", + "_comment": "This is a distribution package for a pre-bundled CLI artifact. Build dependencies are defined in the root package.json. Run 'yarn build-profile-query-cli' from the repository root to build dist/profiler-cli.js.", + "name": "@firefox-profiler/profiler-cli", "version": "0.1.0", "description": "Command-line interface for querying Firefox Profiler profiles with persistent daemon sessions", - "main": "./dist/pq.js", + "main": "./dist/profiler-cli.js", "bin": { - "pq": "./dist/pq.js" + "profiler-cli": "./dist/profiler-cli.js" }, "files": [ - "dist/pq.js" + "dist/profiler-cli.js" ], "engines": { "node": ">= 24 < 25" diff --git a/profile-query-cli/schemas.txt b/profile-query-cli/schemas.txt index 598879ef4c..321778a22b 100644 --- a/profile-query-cli/schemas.txt +++ b/profile-query-cli/schemas.txt @@ -1,10 +1,10 @@ -pq: JSON Output Schemas +profiler-cli: JSON Output Schemas Add --json to any command to get structured JSON output. The schemas below document the exact fields returned by each command. -pq profile info --json +profiler-cli profile info --json { type: "profile-info", name, platform, threadCount, processCount, @@ -17,7 +17,7 @@ pq profile info --json context: SessionContext } -pq thread samples --json +profiler-cli thread samples --json { type: "thread-samples", threadHandle, friendlyThreadName, activeOnly?, @@ -34,7 +34,7 @@ pq thread samples --json context: SessionContext } -pq thread samples-top-down --json +profiler-cli thread samples-top-down --json { type: "thread-samples-top-down", threadHandle, friendlyThreadName, activeOnly?, @@ -43,7 +43,7 @@ pq thread samples-top-down --json context: SessionContext } -pq thread samples-bottom-up --json +profiler-cli thread samples-bottom-up --json { type: "thread-samples-bottom-up", threadHandle, friendlyThreadName, activeOnly?, @@ -63,7 +63,7 @@ CallTreeNode (recursive): maxSamples, maxPercentage, depth } } -pq thread markers --json +profiler-cli thread markers --json { type: "thread-markers", threadHandle, friendlyThreadName, @@ -80,7 +80,7 @@ pq thread markers --json context: SessionContext } -pq thread functions --json +profiler-cli thread functions --json { type: "thread-functions", threadHandle, friendlyThreadName, activeOnly?, @@ -92,7 +92,7 @@ pq thread functions --json context: SessionContext } -pq marker info --json +profiler-cli marker info --json { type: "marker-info", threadHandle, friendlyThreadName, markerHandle, markerIndex, name, @@ -102,7 +102,7 @@ pq marker info --json stack?: { frames: [{ name, nameWithLibrary }], truncated } } -pq status --json +profiler-cli status --json { type: "status", selectedThreadHandle, diff --git a/profile-query-cli/src/client.ts b/profile-query-cli/src/client.ts index 0b401c0d06..3c6c681283 100644 --- a/profile-query-cli/src/client.ts +++ b/profile-query-cli/src/client.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Client for communicating with the pq daemon. + * Client for communicating with the profiler-cli daemon. */ import * as net from 'net'; @@ -135,7 +135,7 @@ async function sendRawMessage( const resolvedSessionId = sessionId || getCurrentSessionId(sessionDir); if (!resolvedSessionId) { - throw new Error('No active session. Run "pq load " first.'); + throw new Error('No active session. Run "profiler-cli load " first.'); } // Validate the session @@ -162,7 +162,7 @@ async function sendRawMessage( : 'The daemon may still be running; stop it before reusing this session id.'; throw new Error( - `Session ${resolvedSessionId} was built with a different version (daemon: ${metadata.buildHash}, client: ${BUILD_HASH}). ${shutdownMessage} Please run "pq load " again.` + `Session ${resolvedSessionId} was built with a different version (daemon: ${metadata.buildHash}, client: ${BUILD_HASH}). ${shutdownMessage} Please run "profiler-cli load " again.` ); } @@ -264,7 +264,7 @@ export async function startNewDaemon( } } - // Get the path to the current script (pq.js) + // Get the path to the current script (profiler-cli.js) const scriptPath = process.argv[1]; // Spawn the daemon process (detached from parent) @@ -274,7 +274,7 @@ export async function startNewDaemon( { detached: true, stdio: 'ignore', // Don't pipe stdin/stdout/stderr - env: { ...process.env, PQ_SESSION_DIR: sessionDir }, // Pass sessionDir via env + env: { ...process.env, PROFILER_CLI_SESSION_DIR: sessionDir }, // Pass sessionDir via env } ); diff --git a/profile-query-cli/src/commands/filter.ts b/profile-query-cli/src/commands/filter.ts index e1eb7056a6..4fb4444472 100644 --- a/profile-query-cli/src/commands/filter.ts +++ b/profile-query-cli/src/commands/filter.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * `pq filter` command. + * `profiler-cli filter` command. */ import type { Command } from 'commander'; @@ -88,7 +88,7 @@ export function registerFilterCommand( console.log(formatOutput(result, opts.json ?? false)); if (!wasExplicit('filter', 'list')) { - console.log('\nOther subcommands: pq filter [options]'); + console.log('\nOther subcommands: profiler-cli filter [options]'); } }); diff --git a/profile-query-cli/src/commands/function.ts b/profile-query-cli/src/commands/function.ts index 6b491275e1..fff8d4c877 100644 --- a/profile-query-cli/src/commands/function.ts +++ b/profile-query-cli/src/commands/function.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * `pq function` command. + * `profiler-cli function` command. */ import type { Command } from 'commander'; diff --git a/profile-query-cli/src/commands/marker.ts b/profile-query-cli/src/commands/marker.ts index 6f15aea19d..d84c93e6dc 100644 --- a/profile-query-cli/src/commands/marker.ts +++ b/profile-query-cli/src/commands/marker.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * `pq marker` command. + * `profiler-cli marker` command. */ import type { Command } from 'commander'; diff --git a/profile-query-cli/src/commands/profile.ts b/profile-query-cli/src/commands/profile.ts index 4c1e02b611..b8d4ec6b6a 100644 --- a/profile-query-cli/src/commands/profile.ts +++ b/profile-query-cli/src/commands/profile.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * `pq profile` command. + * `profiler-cli profile` command. */ import type { Command } from 'commander'; diff --git a/profile-query-cli/src/commands/session.ts b/profile-query-cli/src/commands/session.ts index 2618666e1b..6ad3e4f4f4 100644 --- a/profile-query-cli/src/commands/session.ts +++ b/profile-query-cli/src/commands/session.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * `pq session` command. + * `profiler-cli session` command. */ import type { Command } from 'commander'; @@ -63,7 +63,7 @@ export function registerSessionCommand( } if (!wasExplicit('session', 'list')) { - console.log('\nOther subcommands: pq session use '); + console.log('\nOther subcommands: profiler-cli session use '); } }); diff --git a/profile-query-cli/src/commands/shared.ts b/profile-query-cli/src/commands/shared.ts index 13ec1487f4..52ec66d31e 100644 --- a/profile-query-cli/src/commands/shared.ts +++ b/profile-query-cli/src/commands/shared.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Shared option helpers for pq CLI commands. + * Shared option helpers for profiler-cli commands. */ import type { Command } from 'commander'; @@ -14,8 +14,8 @@ import { collectStrings } from '../utils/parse'; * Returns true if the given subcommand was explicitly typed by the user. * Used to decide whether to print a "other subcommands" hint after a default action. * - * e.g. `pq session` → wasExplicit('session', 'list') === false - * `pq session list` → wasExplicit('session', 'list') === true + * e.g. `profiler-cli session` → wasExplicit('session', 'list') === false + * `profiler-cli session list` → wasExplicit('session', 'list') === true */ export function wasExplicit(parent: string, subcommand: string): boolean { const args = process.argv; diff --git a/profile-query-cli/src/commands/thread.ts b/profile-query-cli/src/commands/thread.ts index a3e9417bae..ecae591e53 100644 --- a/profile-query-cli/src/commands/thread.ts +++ b/profile-query-cli/src/commands/thread.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * `pq thread` command. + * `profiler-cli thread` command. */ import type { Command } from 'commander'; diff --git a/profile-query-cli/src/commands/zoom.ts b/profile-query-cli/src/commands/zoom.ts index 0e550d99f4..26232030cb 100644 --- a/profile-query-cli/src/commands/zoom.ts +++ b/profile-query-cli/src/commands/zoom.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * `pq zoom` command. + * `profiler-cli zoom` command. */ import type { Command } from 'commander'; diff --git a/profile-query-cli/src/daemon.ts b/profile-query-cli/src/daemon.ts index f14ee5c13e..04e8a27625 100644 --- a/profile-query-cli/src/daemon.ts +++ b/profile-query-cli/src/daemon.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Daemon process for pq. + * Daemon process for profiler-cli. * Loads a profile and listens for commands on a Unix socket (or named pipe on Windows). */ diff --git a/profile-query-cli/src/formatters.ts b/profile-query-cli/src/formatters.ts index c618b31edb..a11fff6c69 100644 --- a/profile-query-cli/src/formatters.ts +++ b/profile-query-cli/src/formatters.ts @@ -209,7 +209,7 @@ export function formatViewRangeResult(result: ViewRangeResult): string { // Show zoom depth if available if (result.zoomDepth !== undefined) { - output += `\n Zoom depth: ${result.zoomDepth}${result.zoomDepth > 1 ? ' (use "pq zoom pop" to go back)' : ''}`; + output += `\n Zoom depth: ${result.zoomDepth}${result.zoomDepth > 1 ? ' (use "profiler-cli zoom pop" to go back)' : ''}`; } } @@ -347,7 +347,7 @@ Marker ${result.markerHandle}: ${result.name}`; } if (result.stack.truncated) { - output += `\nUse 'pq marker stack ${result.markerHandle}' for the full stack trace.\n`; + output += `\nUse 'profiler-cli marker stack ${result.markerHandle}' for the full stack trace.\n`; } } @@ -578,7 +578,7 @@ Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}${filtersN // Top functions by total time output += 'Top Functions (by total time):\n'; output += - ' (For a call tree starting from these functions, use: pq thread samples-top-down)\n\n'; + ' (For a call tree starting from these functions, use: profiler-cli thread samples-top-down)\n\n'; for (const func of result.topFunctionsByTotal) { const totalCount = Math.round(func.totalSamples); const totalPct = func.totalPercentage.toFixed(1); @@ -591,7 +591,7 @@ Thread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}${filtersN // Top functions by self time output += 'Top Functions (by self time):\n'; output += - ' (For a call tree showing what calls these functions, use: pq thread samples-bottom-up)\n\n'; + ' (For a call tree showing what calls these functions, use: profiler-cli thread samples-bottom-up)\n\n'; for (const func of result.topFunctionsBySelf) { const selfCount = Math.round(func.selfSamples); const selfPct = func.selfPercentage.toFixed(1); @@ -839,7 +839,7 @@ export function formatThreadMarkersResult( } lines.push( - 'Use --search , --category , --min-duration , --max-duration , --has-stack, --limit , --group-by , --auto-group, or --top-n to filter/group markers, or m- handles to inspect individual markers or zoom into their time range (pq zoom push m-).' + 'Use --search , --category , --min-duration , --max-duration , --has-stack, --limit , --group-by , --auto-group, or --top-n to filter/group markers, or m- handles to inspect individual markers or zoom into their time range (profiler-cli zoom push m-).' ); return lines.join('\n'); diff --git a/profile-query-cli/src/index.ts b/profile-query-cli/src/index.ts index f3180fae8e..572b1fa476 100644 --- a/profile-query-cli/src/index.ts +++ b/profile-query-cli/src/index.ts @@ -3,23 +3,23 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * CLI entry point for pq (Profile Querier). + * CLI entry point for profiler-cli (Profile Querier). * * Usage: - * pq load [--session ] Start a new daemon and load a profile - * pq profile info [--session ] Print profile summary - * pq thread info [--thread ] Print thread information - * pq thread samples [--thread ] Show thread call tree and top functions - * pq stop [] [--all] Stop the daemon - * pq session list List all running sessions - * pq session use Switch the current session + * profiler-cli load [--session ] Start a new daemon and load a profile + * profiler-cli profile info [--session ] Print profile summary + * profiler-cli thread info [--thread ] Print thread information + * profiler-cli thread samples [--thread ] Show thread call tree and top functions + * profiler-cli stop [] [--all] Stop the daemon + * profiler-cli session list List all running sessions + * profiler-cli session use Switch the current session * * Build: * yarn build-profile-query-cli * * Run: - * pq (if pq is in PATH) - * ./profile-query-cli/dist/pq.js (direct invocation) + * profiler-cli (if profiler-cli is in PATH) + * ./profile-query-cli/dist/profiler-cli.js (direct invocation) */ import * as path from 'path'; @@ -42,7 +42,7 @@ import { registerSessionCommand } from './commands/session'; // Read session directory from environment (only place this is read) const SESSION_DIR = - process.env.PQ_SESSION_DIR || path.join(os.homedir(), '.pq'); + process.env.PROFILER_CLI_SESSION_DIR || path.join(os.homedir(), '.profiler-cli'); async function main(): Promise { const rawArgs = process.argv.slice(2); @@ -65,7 +65,7 @@ async function main(): Promise { const program = new Command(); program - .name('pq') + .name('profiler-cli') .description('Profile Querier — query Firefox profiles from the terminal') .helpOption('-h, --help', 'Show help') .addHelpCommand('help [command]', 'Show help for a command') @@ -73,26 +73,26 @@ async function main(): Promise { 'after', ` Examples: - pq load profile.json.gz - pq profile info - pq thread info - pq thread samples - pq thread functions --search GC --min-self 1 - pq thread markers --search DOMEvent --category Graphics - pq zoom push 2.7,3.1 - pq filter push --excludes-function f-184 - pq status - pq stop --all` + profiler-cli load profile.json.gz + profiler-cli profile info + profiler-cli thread info + profiler-cli thread samples + profiler-cli thread functions --search GC --min-self 1 + profiler-cli thread markers --search DOMEvent --category Graphics + profiler-cli zoom push 2.7,3.1 + profiler-cli filter push --excludes-function f-184 + profiler-cli status + profiler-cli stop --all` ); - // Bare `pq` with no arguments + // Bare `profiler-cli` with no arguments program.action(() => { console.error('Error: No command specified\n'); program.outputHelp(); process.exit(1); }); - // pq load + // profiler-cli load addGlobalOptions( program .command('load ') @@ -107,7 +107,7 @@ Examples: console.log(`Session started: ${sessionId}`); }); - // pq status + // profiler-cli status addGlobalOptions( program .command('status') @@ -123,7 +123,7 @@ Examples: console.log(formatOutput(result, opts.json ?? false)); }); - // pq stop [id] + // profiler-cli stop [id] addGlobalOptions( program .command('stop [id]') @@ -143,7 +143,7 @@ Examples: } }); - // pq guide + // profiler-cli guide program .command('guide') .description('Show detailed usage guide (commands, patterns, tips)') @@ -151,7 +151,7 @@ Examples: console.log(guideText); }); - // pq schemas + // profiler-cli schemas program .command('schemas') .description('Show JSON output schemas for all commands') diff --git a/profile-query-cli/src/output.ts b/profile-query-cli/src/output.ts index 60edaf70cf..bcb8d931aa 100644 --- a/profile-query-cli/src/output.ts +++ b/profile-query-cli/src/output.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Output formatting for pq CLI commands. + * Output formatting for profiler-cli commands. */ import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; diff --git a/profile-query-cli/src/protocol.ts b/profile-query-cli/src/protocol.ts index 1a9a106c55..d3f693f9f6 100644 --- a/profile-query-cli/src/protocol.ts +++ b/profile-query-cli/src/protocol.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Protocol for communication between pq client and daemon. + * Protocol for communication between profiler-cli client and daemon. * Messages are sent as line-delimited JSON over Unix domain sockets. */ diff --git a/profile-query-cli/src/session.ts b/profile-query-cli/src/session.ts index e45513a1e4..5932919830 100644 --- a/profile-query-cli/src/session.ts +++ b/profile-query-cli/src/session.ts @@ -3,11 +3,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Session management for pq daemon. + * Session management for profiler-cli daemon. * Handles session files, socket paths, and current session tracking. * * All functions take an explicit sessionDir parameter for testability - * and to avoid global state. The CLI entry point reads PQ_SESSION_DIR + * and to avoid global state. The CLI entry point reads PROFILER_CLI_SESSION_DIR * once and passes it through. */ @@ -51,7 +51,7 @@ export function getSessionDirNamespace(sessionDir: string): string { export function getSocketPath(sessionDir: string, sessionId: string): string { if (process.platform === 'win32') { const sessionDirNamespace = getSessionDirNamespace(sessionDir); - return `\\\\.\\pipe\\pq-${sessionDirNamespace}-${sessionId}`; + return `\\\\.\\pipe\\profiler-cli-${sessionDirNamespace}-${sessionId}`; } return path.join(sessionDir, `${sessionId}.sock`); } diff --git a/profile-query-cli/src/test/integration/basic.test.ts b/profile-query-cli/src/test/integration/basic.test.ts index a9777f519a..330c63b7e2 100644 --- a/profile-query-cli/src/test/integration/basic.test.ts +++ b/profile-query-cli/src/test/integration/basic.test.ts @@ -4,7 +4,6 @@ /** * Basic CLI functionality tests. - * Migrated from bin/pq-test bash script. */ import { readdir, readFile, writeFile } from 'fs/promises'; @@ -12,13 +11,13 @@ import { join } from 'path'; import { createTestContext, cleanupTestContext, - pq, - pqFail, - type PqTestContext, + cli, + cliFail, + type CliTestContext, } from './utils'; -describe('pq basic functionality', () => { - let ctx: PqTestContext; +describe('profiler-cli basic functionality', () => { + let ctx: CliTestContext; beforeEach(async () => { ctx = await createTestContext(); @@ -29,7 +28,7 @@ describe('pq basic functionality', () => { }); it('load creates a session', async () => { - const result = await pq(ctx, [ + const result = await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-1.json', ]); @@ -56,18 +55,18 @@ describe('pq basic functionality', () => { }); it('profile info works after load', async () => { - await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); - const result = await pq(ctx, ['profile', 'info']); + const result = await cli(ctx, ['profile', 'info']); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('This profile contains'); }); it('thread select works immediately after load', async () => { - await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); - const result = await pq(ctx, ['thread', 'select', 't-0']); + const result = await cli(ctx, ['thread', 'select', 't-0']); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Selected thread'); @@ -75,8 +74,8 @@ describe('pq basic functionality', () => { }); it('stop cleans up session', async () => { - await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); - await pq(ctx, ['stop']); + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + await cli(ctx, ['stop']); // Verify socket is removed (the main cleanup requirement) const files = await readdir(ctx.sessionDir); @@ -84,7 +83,7 @@ describe('pq basic functionality', () => { }); it('load fails for missing file', async () => { - const result = await pqFail(ctx, ['load', '/nonexistent/file.json']); + const result = await cliFail(ctx, ['load', '/nonexistent/file.json']); expect(result.exitCode).not.toBe(0); const output = String(result.stdout || '') + String(result.stderr || ''); @@ -92,7 +91,7 @@ describe('pq basic functionality', () => { }); it('profile info fails without active session', async () => { - const result = await pqFail(ctx, ['profile', 'info']); + const result = await cliFail(ctx, ['profile', 'info']); expect(result.exitCode).not.toBe(0); const output = String(result.stdout || '') + String(result.stderr || ''); @@ -100,22 +99,22 @@ describe('pq basic functionality', () => { }); it('multiple profile info calls work (daemon stays running)', async () => { - await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); // First call - const result1 = await pq(ctx, ['profile', 'info']); + const result1 = await cli(ctx, ['profile', 'info']); expect(result1.exitCode).toBe(0); // Second call - should still work (daemon running) - const result2 = await pq(ctx, ['profile', 'info']); + const result2 = await cli(ctx, ['profile', 'info']); expect(result2.exitCode).toBe(0); expect(result2.stdout).toEqual(result1.stdout); }); it('numeric zero marker filters are preserved instead of being ignored', async () => { - await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); - const minDurationResult = await pq(ctx, [ + const minDurationResult = await cli(ctx, [ 'thread', 'markers', '--json', @@ -124,7 +123,7 @@ describe('pq basic functionality', () => { ]); expect(minDurationResult.stdout).toContain('"minDuration": 0'); - const maxDurationResult = await pq(ctx, [ + const maxDurationResult = await cli(ctx, [ 'thread', 'markers', '--json', @@ -135,9 +134,9 @@ describe('pq basic functionality', () => { }); it('numeric zero function filters are preserved instead of being ignored', async () => { - await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); - const result = await pq(ctx, [ + const result = await cli(ctx, [ 'thread', 'functions', '--json', @@ -149,12 +148,12 @@ describe('pq basic functionality', () => { }); it('sticky filters are isolated per thread and reported in status', async () => { - await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); - await pq(ctx, ['thread', 'select', 't-0']); + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + await cli(ctx, ['thread', 'select', 't-0']); - await pq(ctx, ['filter', 'push', '--merge', 'f-1']); + await cli(ctx, ['filter', 'push', '--merge', 'f-1']); - const filterListResult = await pq(ctx, ['filter', 'list', '--json']); + const filterListResult = await cli(ctx, ['filter', 'list', '--json']); const filterList = JSON.parse(filterListResult.stdout) as { type: string; threadHandle: string; @@ -171,7 +170,7 @@ describe('pq basic functionality', () => { funcIndexes: [1], }); - const statusResult = await pq(ctx, ['status', '--json']); + const statusResult = await cli(ctx, ['status', '--json']); const status = JSON.parse(statusResult.stdout) as { type: string; filterStacks: Array<{ @@ -195,9 +194,9 @@ describe('pq basic functionality', () => { }) ); - await pq(ctx, ['thread', 'select', 't-1']); + await cli(ctx, ['thread', 'select', 't-1']); - const otherThreadFilterListResult = await pq(ctx, [ + const otherThreadFilterListResult = await cli(ctx, [ 'filter', 'list', '--json', @@ -212,7 +211,7 @@ describe('pq basic functionality', () => { expect(otherThreadFilterList.threadHandle).toBe('t-1'); expect(otherThreadFilterList.filters).toHaveLength(0); - const explicitThreadFilterListResult = await pq(ctx, [ + const explicitThreadFilterListResult = await cli(ctx, [ 'filter', 'list', '--thread', @@ -237,9 +236,9 @@ describe('pq basic functionality', () => { }); it('ephemeral sample filters do not persist into session state', async () => { - await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); - const samplesResult = await pq(ctx, [ + const samplesResult = await cli(ctx, [ 'thread', 'samples', '--json', @@ -258,13 +257,13 @@ describe('pq basic functionality', () => { ]); expect(samples.activeFilters).toBeUndefined(); - const filterListResult = await pq(ctx, ['filter', 'list', '--json']); + const filterListResult = await cli(ctx, ['filter', 'list', '--json']); const filterList = JSON.parse(filterListResult.stdout) as { filters: unknown[]; }; expect(filterList.filters).toHaveLength(0); - const statusResult = await pq(ctx, ['status', '--json']); + const statusResult = await cli(ctx, ['status', '--json']); const status = JSON.parse(statusResult.stdout) as { filterStacks: unknown[]; }; @@ -272,9 +271,9 @@ describe('pq basic functionality', () => { }); it('max-lines=0 is rejected instead of silently falling back to the default', async () => { - await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); - const result = await pqFail(ctx, [ + const result = await cliFail(ctx, [ 'thread', 'samples-top-down', '--max-lines', @@ -287,7 +286,7 @@ describe('pq basic functionality', () => { }); it('build hash mismatch stops the daemon before cleaning up the session', async () => { - const loadResult = await pq(ctx, [ + const loadResult = await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-1.json', ]); @@ -308,7 +307,7 @@ describe('pq basic functionality', () => { JSON.stringify({ ...metadata, buildHash: 'intentionally-mismatched' }) ); - const result = await pqFail(ctx, ['profile', 'info']); + const result = await cliFail(ctx, ['profile', 'info']); const output = String(result.stdout || '') + String(result.stderr || ''); expect(output).toContain('was built with a different version'); expect(output).toContain('The daemon is no longer running'); diff --git a/profile-query-cli/src/test/integration/daemon-startup.test.ts b/profile-query-cli/src/test/integration/daemon-startup.test.ts index c879e1a239..0afae28101 100644 --- a/profile-query-cli/src/test/integration/daemon-startup.test.ts +++ b/profile-query-cli/src/test/integration/daemon-startup.test.ts @@ -12,14 +12,14 @@ import { join } from 'path'; import { createTestContext, cleanupTestContext, - pq, - pqFail, - type PqTestContext, + cli, + cliFail, + type CliTestContext, } from './utils'; import { getSocketPath } from '../../session'; describe('daemon startup (two-phase)', () => { - let ctx: PqTestContext; + let ctx: CliTestContext; beforeEach(async () => { ctx = await createTestContext(); @@ -32,7 +32,7 @@ describe('daemon startup (two-phase)', () => { it('daemon creates socket and metadata before loading profile', async () => { const startTime = Date.now(); - const result = await pq(ctx, [ + const result = await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-1.json', ]); @@ -68,7 +68,7 @@ describe('daemon startup (two-phase)', () => { const { writeFile } = await import('fs/promises'); await writeFile(invalidProfile, '{ invalid json content', 'utf-8'); - const result = await pqFail(ctx, ['load', invalidProfile]); + const result = await cliFail(ctx, ['load', invalidProfile]); expect(result.exitCode).not.toBe(0); const output = String(result.stdout || '') + String(result.stderr || ''); @@ -80,7 +80,7 @@ describe('daemon startup (two-phase)', () => { // We can't easily force a daemon startup failure, but we can // verify the timeout is reasonable by checking it doesn't wait forever - const result = await pqFail(ctx, ['load', '/nonexistent/file.json']); + const result = await cliFail(ctx, ['load', '/nonexistent/file.json']); // Should fail quickly (Phase 1: 500ms for daemon, Phase 2: fails on validation) expect(result.exitCode).not.toBe(0); @@ -88,16 +88,16 @@ describe('daemon startup (two-phase)', () => { it('load blocks until profile is fully loaded', async () => { // Start loading - await pq(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); // If load returned, profile should be ready immediately - const result = await pq(ctx, ['profile', 'info']); + const result = await cli(ctx, ['profile', 'info']); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('This profile contains'); }); it('validates session before returning (checks process + socket)', async () => { - const result = await pq(ctx, [ + const result = await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-1.json', ]); diff --git a/profile-query-cli/src/test/integration/sessions.test.ts b/profile-query-cli/src/test/integration/sessions.test.ts index bcbf82e6d6..1b1ee29113 100644 --- a/profile-query-cli/src/test/integration/sessions.test.ts +++ b/profile-query-cli/src/test/integration/sessions.test.ts @@ -4,7 +4,6 @@ /** * Multi-session tests. - * Migrated from bin/pq-test-multi bash script. */ import { access, writeFile } from 'fs/promises'; @@ -12,13 +11,13 @@ import { join } from 'path'; import { createTestContext, cleanupTestContext, - pq, - pqFail, - type PqTestContext, + cli, + cliFail, + type CliTestContext, } from './utils'; -describe('pq multiple concurrent sessions', () => { - let ctx: PqTestContext; +describe('profiler-cli multiple concurrent sessions', () => { + let ctx: CliTestContext; beforeEach(async () => { ctx = await createTestContext(); @@ -34,19 +33,19 @@ describe('pq multiple concurrent sessions', () => { const session3 = 'test-session-3'; // Start three sessions - await pq(ctx, [ + await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-1.json', '--session', session1, ]); - await pq(ctx, [ + await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-2.json', '--session', session2, ]); - await pq(ctx, [ + await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-3.json', '--session', @@ -54,34 +53,34 @@ describe('pq multiple concurrent sessions', () => { ]); // Query each session explicitly - const result1 = await pq(ctx, ['profile', 'info', '--session', session1]); + const result1 = await cli(ctx, ['profile', 'info', '--session', session1]); expect(result1.stdout).toContain('This profile contains'); - const result2 = await pq(ctx, ['profile', 'info', '--session', session2]); + const result2 = await cli(ctx, ['profile', 'info', '--session', session2]); expect(result2.stdout).toContain('This profile contains'); // Query current session (should be session3) - const result3 = await pq(ctx, ['profile', 'info']); + const result3 = await cli(ctx, ['profile', 'info']); expect(result3.stdout).toContain('This profile contains'); // Note: We don't assert that results differ, as different test profiles // might coincidentally have identical summaries. // Stop all sessions (mix of positional arg and --session flag) - await pq(ctx, ['stop', session1]); - await pq(ctx, ['stop', '--session', session2]); - await pq(ctx, ['stop', session3]); + await cli(ctx, ['stop', session1]); + await cli(ctx, ['stop', '--session', session2]); + await cli(ctx, ['stop', session3]); }); it('session list shows running sessions and marks the current one', async () => { // Start two sessions - await pq(ctx, [ + await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-1.json', '--session', 'session-a', ]); - await pq(ctx, [ + await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-2.json', '--session', @@ -89,7 +88,7 @@ describe('pq multiple concurrent sessions', () => { ]); // List sessions — session-b was loaded last, so it should be current - const result = await pq(ctx, ['session', 'list']); + const result = await cli(ctx, ['session', 'list']); expect(result.stdout).toContain('Found 2 running sessions'); expect(result.stdout).toContain('session-a'); @@ -97,17 +96,17 @@ describe('pq multiple concurrent sessions', () => { expect(result.stdout).toMatch(/\* session-b/); // Clean up - await pq(ctx, ['stop', '--all']); + await cli(ctx, ['stop', '--all']); }); it('session use switches the current session', async () => { - await pq(ctx, [ + await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-1.json', '--session', 'session-a', ]); - await pq(ctx, [ + await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-2.json', '--session', @@ -115,25 +114,25 @@ describe('pq multiple concurrent sessions', () => { ]); // session-b is current; switch to session-a - const switchResult = await pq(ctx, ['session', 'use', 'session-a']); + const switchResult = await cli(ctx, ['session', 'use', 'session-a']); expect(switchResult.stdout).toContain('Switched to session session-a'); // session list should now mark session-a as current - const listResult = await pq(ctx, ['session', 'list']); + const listResult = await cli(ctx, ['session', 'list']); expect(listResult.stdout).toMatch(/\* session-a/); - await pq(ctx, ['stop', '--all']); + await cli(ctx, ['stop', '--all']); }); it('stop --all stops all sessions', async () => { // Start multiple sessions - await pq(ctx, [ + await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-1.json', '--session', 'session-1', ]); - await pq(ctx, [ + await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-2.json', '--session', @@ -141,28 +140,28 @@ describe('pq multiple concurrent sessions', () => { ]); // Stop all - await pq(ctx, ['stop', '--all']); + await cli(ctx, ['stop', '--all']); // Verify no sessions - const result = await pq(ctx, ['session', 'list']); + const result = await cli(ctx, ['session', 'list']); expect(result.stdout).toContain('Found 0 running sessions'); }); it('session use with unknown id fails', async () => { - const result = await pqFail(ctx, ['session', 'use', 'does-not-exist']); + const result = await cliFail(ctx, ['session', 'use', 'does-not-exist']); expect(result.exitCode).not.toBe(0); const output = String(result.stdout || '') + String(result.stderr || ''); expect(output).toContain('does-not-exist'); }); it('session use causes unqualified commands to target the switched session', async () => { - await pq(ctx, [ + await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-1.json', '--session', 'session-a', ]); - await pq(ctx, [ + await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-2.json', '--session', @@ -170,30 +169,35 @@ describe('pq multiple concurrent sessions', () => { ]); // Switch to session-a (session-b is current) - await pq(ctx, ['session', 'use', 'session-a']); + await cli(ctx, ['session', 'use', 'session-a']); // Unqualified stop should stop session-a - await pq(ctx, ['stop']); + await cli(ctx, ['stop']); // session-a is gone; session-b is still running - await pqFail(ctx, ['profile', 'info', '--session', 'session-a']); - const result = await pq(ctx, ['profile', 'info', '--session', 'session-b']); + await cliFail(ctx, ['profile', 'info', '--session', 'session-a']); + const result = await cli(ctx, [ + 'profile', + 'info', + '--session', + 'session-b', + ]); expect(result.exitCode).toBe(0); - await pq(ctx, ['stop', '--all']); + await cli(ctx, ['stop', '--all']); }); it('reusing a live explicit session id fails without replacing the daemon', async () => { const sessionId = 'shared-session'; - await pq(ctx, [ + await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-1.json', '--session', sessionId, ]); - const secondLoad = await pqFail(ctx, [ + const secondLoad = await cliFail(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-2.json', '--session', @@ -205,7 +209,7 @@ describe('pq multiple concurrent sessions', () => { String(secondLoad.stdout || '') + String(secondLoad.stderr || ''); expect(output).toContain(`Session ${sessionId} is already running`); - const result = await pq(ctx, ['profile', 'info', '--session', sessionId]); + const result = await cli(ctx, ['profile', 'info', '--session', sessionId]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('This profile contains'); }); @@ -235,7 +239,7 @@ describe('pq multiple concurrent sessions', () => { 'utf-8' ); - const result = await pq(ctx, ['session', 'list']); + const result = await cli(ctx, ['session', 'list']); expect(result.stdout).toContain('Cleaned up 1 stale sessions.'); expect(result.stdout).toContain('Found 0 running sessions'); diff --git a/profile-query-cli/src/test/integration/utils.ts b/profile-query-cli/src/test/integration/utils.ts index 3407b2f818..c8a82255dd 100644 --- a/profile-query-cli/src/test/integration/utils.ts +++ b/profile-query-cli/src/test/integration/utils.ts @@ -11,7 +11,7 @@ import { mkdtemp, readdir, readFile, rm } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; -const PQ_BIN = './profile-query-cli/dist/pq.js'; +const CLI_BIN = './profile-query-cli/dist/profiler-cli.js'; /** * Simple command execution result. @@ -82,9 +82,9 @@ function exec( } /** - * Context for a pq test session. + * Context for a profiler-cli test session. */ -export interface PqTestContext { +export interface CliTestContext { sessionDir: string; env: Record; } @@ -93,11 +93,11 @@ export interface PqTestContext { * Create a test context with isolated session directory. * Each test should call this in beforeEach() for maximum isolation. */ -export async function createTestContext(): Promise { - const sessionDir = await mkdtemp(join(tmpdir(), 'pq-test-')); +export async function createTestContext(): Promise { + const sessionDir = await mkdtemp(join(tmpdir(), 'profiler-cli-test-')); return { sessionDir, - env: { PQ_SESSION_DIR: sessionDir }, + env: { PROFILER_CLI_SESSION_DIR: sessionDir }, }; } @@ -136,23 +136,23 @@ async function killSessionDaemons(sessionDir: string): Promise { * Clean up test context. * Each test should call this in afterEach() to remove temp directory. */ -export async function cleanupTestContext(ctx: PqTestContext): Promise { +export async function cleanupTestContext(ctx: CliTestContext): Promise { await killSessionDaemons(ctx.sessionDir); await rm(ctx.sessionDir, { recursive: true, force: true }); } /** - * Run a pq command. + * Run a profiler-cli command. */ -export async function runPq( - ctx: PqTestContext, +export async function runCli( + ctx: CliTestContext, args: string[], options?: { reject?: boolean; timeout?: number; } ): Promise { - const result = await exec(process.execPath, [PQ_BIN, ...args], { + const result = await exec(process.execPath, [CLI_BIN, ...args], { env: ctx.env, timeout: options?.timeout ?? 30000, }); @@ -168,24 +168,24 @@ export async function runPq( } /** - * Run a pq command and expect it to succeed. + * Run a profiler-cli command and expect it to succeed. */ -export async function pq( - ctx: PqTestContext, +export async function cli( + ctx: CliTestContext, args: string[] ): Promise { - return runPq(ctx, args); + return runCli(ctx, args); } /** - * Run a pq command and expect it to fail. + * Run a profiler-cli command and expect it to fail. */ -export async function pqFail( - ctx: PqTestContext, +export async function cliFail( + ctx: CliTestContext, args: string[] ): Promise { try { - await runPq(ctx, args); + await runCli(ctx, args); throw new Error('Expected command to fail but it succeeded'); } catch (error) { if (error instanceof Error && error.message.includes('Expected command')) { diff --git a/profile-query-cli/src/test/unit/client.test.ts b/profile-query-cli/src/test/unit/client.test.ts index cbc5831b16..91a7cb4004 100644 --- a/profile-query-cli/src/test/unit/client.test.ts +++ b/profile-query-cli/src/test/unit/client.test.ts @@ -13,8 +13,8 @@ * * Instead, client functionality is tested through integration tests in bash * scripts: - * - bin/pq-test: Basic daemon lifecycle and client-server communication - * - bin/pq-test-multi: Concurrent client sessions + * - bin/profiler-cli-test: Basic daemon lifecycle and client-server communication + * - bin/profiler-cli-test-multi: Concurrent client sessions * * Do not add unit tests here. If you need to test pure utility functions from * client.ts, extract them to a separate module and test that module instead. diff --git a/profile-query-cli/src/test/unit/daemon.test.ts b/profile-query-cli/src/test/unit/daemon.test.ts index 5f42834c75..7a4f1f4783 100644 --- a/profile-query-cli/src/test/unit/daemon.test.ts +++ b/profile-query-cli/src/test/unit/daemon.test.ts @@ -13,8 +13,8 @@ * * Instead, daemon functionality is tested through integration tests in bash * scripts: - * - bin/pq-test: Basic daemon lifecycle (start, connect, stop) - * - bin/pq-test-multi: Multiple concurrent daemon sessions + * - bin/profiler-cli-test: Basic daemon lifecycle (start, connect, stop) + * - bin/profiler-cli-test-multi: Multiple concurrent daemon sessions * * Do not add unit tests here. If you need to test pure utility functions from * daemon.ts, extract them to a separate module and test that module instead. diff --git a/profile-query-cli/src/test/unit/session.test.ts b/profile-query-cli/src/test/unit/session.test.ts index e07d770f47..8c92901e2d 100644 --- a/profile-query-cli/src/test/unit/session.test.ts +++ b/profile-query-cli/src/test/unit/session.test.ts @@ -7,8 +7,8 @@ * * These tests cover only the session.ts utility functions. * Integration tests that spawn daemons and test IPC are in bash scripts: - * - bin/pq-test: Basic daemon lifecycle - * - bin/pq-test-multi: Concurrent sessions + * - bin/profiler-cli-test: Basic daemon lifecycle + * - bin/profiler-cli-test-multi: Concurrent sessions */ import * as fs from 'fs'; @@ -46,7 +46,7 @@ describe('profile-query-cli session management', function () { beforeEach(function () { // Create a unique temp directory for each test - testSessionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pq-test-')); + testSessionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'profiler-cli-test-')); }); afterEach(function () { @@ -116,15 +116,15 @@ describe('profile-query-cli session management', function () { configurable: true, }); - const firstSocketPath = getSocketPath('C:\\pq\\alpha', 'test123'); - const secondSocketPath = getSocketPath('C:\\pq\\beta', 'test123'); - const thirdSocketPath = getSocketPath('C:\\PQ\\ALPHA', 'test123'); + const firstSocketPath = getSocketPath('C:\\profiler-cli\\alpha', 'test123'); + const secondSocketPath = getSocketPath('C:\\profiler-cli\\beta', 'test123'); + const thirdSocketPath = getSocketPath('C:\\PROFILER-CLI\\ALPHA', 'test123'); expect(firstSocketPath).toMatch( - /^\\\\\.\\pipe\\pq-[0-9a-f]{12}-test123$/ + /^\\\\\.\\pipe\\profiler-cli-[0-9a-f]{12}-test123$/ ); expect(secondSocketPath).toMatch( - /^\\\\\.\\pipe\\pq-[0-9a-f]{12}-test123$/ + /^\\\\\.\\pipe\\profiler-cli-[0-9a-f]{12}-test123$/ ); expect(firstSocketPath).not.toBe(secondSocketPath); expect(firstSocketPath).toBe(thirdSocketPath); @@ -136,9 +136,9 @@ describe('profile-query-cli session management', function () { }); it('generates a stable namespace from the session directory', function () { - const firstNamespace = getSessionDirNamespace('C:\\pq\\alpha'); - const secondNamespace = getSessionDirNamespace('C:\\pq\\beta'); - const thirdNamespace = getSessionDirNamespace('C:\\PQ\\ALPHA'); + const firstNamespace = getSessionDirNamespace('C:\\profiler-cli\\alpha'); + const secondNamespace = getSessionDirNamespace('C:\\profiler-cli\\beta'); + const thirdNamespace = getSessionDirNamespace('C:\\PROFILER-CLI\\ALPHA'); expect(firstNamespace).toMatch(/^[0-9a-f]{12}$/); expect(firstNamespace).not.toBe(secondNamespace); diff --git a/profile-query-cli/src/utils/parse.ts b/profile-query-cli/src/utils/parse.ts index d9f5612723..7b648a45aa 100644 --- a/profile-query-cli/src/utils/parse.ts +++ b/profile-query-cli/src/utils/parse.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Shared argument parsing utilities for pq CLI commands. + * Shared argument parsing utilities for profiler-cli commands. */ import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; @@ -115,7 +115,7 @@ export function parseEphemeralFilters( } /** - * Parse exactly one SampleFilterSpec from CLI options for `pq filter push`. + * Parse exactly one SampleFilterSpec from CLI options for `profiler-cli filter push`. * Exactly one filter flag must be provided. */ export function parseFilterSpec(opts: EphemeralFilterOpts): SampleFilterSpec { diff --git a/pq-case-study-2.md b/profiler-cli-case-study-2.md similarity index 81% rename from pq-case-study-2.md rename to profiler-cli-case-study-2.md index d0055461c8..d9c75d35f3 100644 --- a/pq-case-study-2.md +++ b/profiler-cli-case-study-2.md @@ -1,20 +1,20 @@ -# pq Case Study 2: Investigating Repeated Rendering Spikes in Firefox +# profiler-cli Case Study 2: Investigating Repeated Rendering Spikes in Firefox **Profile:** https://share.firefox.dev/4oLEjCw **Date:** November 4, 2025 -**Investigator:** Claude (via pq CLI) +**Investigator:** Claude (via profiler-cli CLI) ## Executive Summary -Using pq, I investigated a Firefox performance profile showing repeated GPU rendering spikes. The investigation revealed that the GPU Renderer thread was spending ~27% of spike time in Present operations (DirectComposition/DXGI), triggered by a loop of WM_PAINT messages on the main thread. The main thread would trigger rendering work, wait for the GPU (FlushRendering), and repeat. +Using profiler-cli, I investigated a Firefox performance profile showing repeated GPU rendering spikes. The investigation revealed that the GPU Renderer thread was spending ~27% of spike time in Present operations (DirectComposition/DXGI), triggered by a loop of WM_PAINT messages on the main thread. The main thread would trigger rendering work, wait for the GPU (FlushRendering), and repeat. ## Investigation Process ### Initial Exploration ```bash -pq load 'https://share.firefox.dev/4oLEjCw' -pq profile info +profiler-cli load 'https://share.firefox.dev/4oLEjCw' +profiler-cli profile info ``` **Observation:** The profile overview immediately showed the GPU process (p-14) consuming 16.1s of CPU, with the Renderer thread (t-93) at 7.9s being the hottest thread. Multiple CPU spike periods were visible at 160% (2 cores). @@ -22,8 +22,8 @@ pq profile info ### Deep Dive into GPU Thread ```bash -pq thread select t-93 -pq thread samples +profiler-cli thread select t-93 +profiler-cli thread samples ``` **Problem:** The output was **extremely verbose** - over 2000 lines for the full profile view. While comprehensive, it required significant scrolling and cognitive effort to digest. The top functions list showed 50 entries before truncating 2224 more. @@ -38,8 +38,8 @@ pq thread samples ### Zooming into Spike Periods ```bash -pq view push ts-6,ts-7 -pq thread samples | head -n 100 +profiler-cli view push ts-6,ts-7 +profiler-cli thread samples | head -n 100 ``` **Positive Experience:** After zooming into a specific spike period (391ms), the output became **much more manageable** - only 179 samples vs 14,466 for the full profile. The percentages shifted dramatically: @@ -51,8 +51,8 @@ pq thread samples | head -n 100 This focused view made it easy to see that during spikes, the thread was spending proportionally more time presenting frames. ```bash -pq status -pq view pop +profiler-cli status +profiler-cli view pop ``` **Positive Experience:** The `status` command clearly showed my current context (selected thread and view range). `view pop` cleanly restored the previous view. @@ -60,8 +60,8 @@ pq view pop ### Investigating the Trigger ```bash -pq thread select t-0 -pq thread samples | head -n 80 +profiler-cli thread select t-0 +profiler-cli thread samples | head -n 80 ``` **Finding:** The main thread (GeckoMain) was: @@ -104,7 +104,7 @@ This immediately highlighted where to investigate, with ready-to-use timestamp r Commands followed predictable patterns: -- `pq ` (e.g., `thread select`, `view push`) +- `profiler-cli ` (e.g., `thread select`, `view push`) - Optional flags for refinement (`--thread t-0`) - Clear, descriptive output @@ -123,7 +123,7 @@ Commands followed predictable patterns: **Suggestion:** Add a `--limit N` flag to truncate output: ```bash -pq thread samples --limit 20 # Show only top 20 functions +profiler-cli thread samples --limit 20 # Show only top 20 functions ``` Or make the default output more concise (e.g., top 15-20 functions only, with an explicit "use --verbose for full output" message). @@ -139,9 +139,9 @@ Or make the default output more concise (e.g., top 15-20 functions only, with an **Suggestion:** Add function search/filter: ```bash -pq thread search "Present" -pq thread functions --filter "atidxx64" # Show only AMD driver functions -pq function info "DCSwapChain::Present" # Details about a specific function +profiler-cli thread search "Present" +profiler-cli thread functions --filter "atidxx64" # Show only AMD driver functions +profiler-cli function info "DCSwapChain::Present" # Details about a specific function ``` ### 3. **Call Tree Format is Hard to Parse** @@ -183,7 +183,7 @@ After 10+ levels of nesting, it's **visually overwhelming** and hard to follow l **Suggestion:** Add range comparison: ```bash -pq view compare ts-6,ts-7 vs ts-8,ts-9 +profiler-cli view compare ts-6,ts-7 vs ts-8,ts-9 # Shows side-by-side differences in top functions ``` @@ -194,9 +194,9 @@ pq view compare ts-6,ts-7 vs ts-8,ts-9 **Suggestion:** Implement marker commands: ```bash -pq thread markers # List recent markers -pq thread markers --type Reflow # Filter by type -pq marker info # Marker details +profiler-cli thread markers # List recent markers +profiler-cli thread markers --type Reflow # Filter by type +profiler-cli marker info # Marker details ``` ### 6. **Missing Symbol Information is Opaque** 🔶 @@ -208,7 +208,7 @@ atidxx64.dll!fun_3e8f0 - total: 2354 (16.3%) atidxx64.dll!fun_a56960 - self: 598 (4.1%) ``` -These are **meaningless** for diagnosis. While it's expected that third-party binaries lack symbols, pq provides **no indication** that: +These are **meaningless** for diagnosis. While it's expected that third-party binaries lack symbols, profiler-cli provides **no indication** that: - These are unsymbolicated - What type of component this is (GPU driver) @@ -233,20 +233,20 @@ These are **meaningless** for diagnosis. While it's expected that third-party bi **Suggestion:** ```bash -pq thread waits # Show all wait operations -pq thread waits --min-duration 10ms # Filter significant waits +profiler-cli thread waits # Show all wait operations +profiler-cli thread waits --min-duration 10ms # Filter significant waits ``` ### 8. **No "Heaviest Stack" or Sample View** ❌ -**Problem:** The profiler UI shows "heaviest stack" (the single most expensive call stack). This is often the smoking gun. pq only shows aggregated functions and trees. +**Problem:** The profiler UI shows "heaviest stack" (the single most expensive call stack). This is often the smoking gun. profiler-cli only shows aggregated functions and trees. **Suggestion:** ```bash -pq thread stacks # Show heaviest individual stacks -pq thread stacks --limit 5 # Top 5 heaviest -pq sample info # Details about a specific sample +profiler-cli thread stacks # Show heaviest individual stacks +profiler-cli thread stacks --limit 5 # Top 5 heaviest +profiler-cli sample info # Details about a specific sample ``` ## Cognitive Load Assessment @@ -299,34 +299,34 @@ pq sample info # Details about a specific sample ### Natural Commands ✓ ```bash -pq load # Obvious -pq profile info # Logical -pq thread select t-93 # Clear -pq thread samples # Descriptive -pq view push ts-6,ts-7 # Intuitive -pq status # Expected +profiler-cli load # Obvious +profiler-cli profile info # Logical +profiler-cli thread select t-93 # Clear +profiler-cli thread samples # Descriptive +profiler-cli view push ts-6,ts-7 # Intuitive +profiler-cli status # Expected ``` ### Awkward Commands ⚠️ -- **Piping to head:** `pq thread samples | head -n 100` - shouldn't need shell plumbing for basic limiting +- **Piping to head:** `profiler-cli thread samples | head -n 100` - shouldn't need shell plumbing for basic limiting - **Filtering not built-in:** Must use `grep` externally -- **No inline thread selection:** `pq thread samples --thread t-93` doesn't work, must select first +- **No inline thread selection:** `profiler-cli thread samples --thread t-93` doesn't work, must select first ### Missing Commands ❌ ```bash -pq thread markers # Not implemented -pq thread waits # Not implemented -pq thread stacks # Not implemented -pq function info # Not implemented -pq view compare # Not implemented -pq thread functions # Not implemented (list top functions only, no tree) +profiler-cli thread markers # Not implemented +profiler-cli thread waits # Not implemented +profiler-cli thread stacks # Not implemented +profiler-cli function info # Not implemented +profiler-cli view compare # Not implemented +profiler-cli thread functions # Not implemented (list top functions only, no tree) ``` ## Handling of Missing Symbols -The profile includes AMD GPU driver code (`atidxx64.dll`) with no symbols. pq handled this **functionally** but **poorly for UX**: +The profile includes AMD GPU driver code (`atidxx64.dll`) with no symbols. profiler-cli handled this **functionally** but **poorly for UX**: ### What Works ✓ @@ -398,7 +398,7 @@ Either: ## Overall Assessment -### pq Strengths 💪 +### profiler-cli Strengths 💪 1. **Progressive exploration** model is natural and effective 2. **Time range navigation** (timestamps + view stack) is excellent @@ -406,7 +406,7 @@ Either: 4. **Profile overview** immediately surfaces hot spots 5. **Consistent command structure** reduces learning curve -### pq Weaknesses 😓 +### profiler-cli Weaknesses 😓 1. **Output verbosity** makes wide-scope views painful 2. **No filtering or search** forces manual grepping @@ -414,7 +414,7 @@ Either: 4. **Poor symbol UX:** Unsymbolicated code looks like real function names 5. **Call tree format** is hard to parse at depth -### Would I Use pq for Real Investigations? +### Would I Use profiler-cli for Real Investigations? **Yes, but with caveats:** @@ -427,7 +427,7 @@ Either: - Manually grep for function names - Copy/paste outputs for comparison -**pq is currently a "first-look tool"** - great for initial exploration, but you'll switch to the profiler UI for serious debugging. +**profiler-cli is currently a "first-look tool"** - great for initial exploration, but you'll switch to the profiler UI for serious debugging. ## Priority Improvements @@ -435,7 +435,7 @@ Either: 1. **Add `--limit` flag** to all commands that generate lists 2. **Implement marker viewing** (thread markers is wired up but not functional) -3. **Add function search/filter** (`pq thread functions --filter "Present"`) +3. **Add function search/filter** (`profiler-cli thread functions --filter "Present"`) ### P1 (High Value) @@ -516,12 +516,12 @@ The **high degree of overlap** between independent investigations of the same pr ## Conclusion -pq is a **promising tool** that successfully enables command-line profile investigation. The core workflow is solid, and for focused investigations (zoomed into specific time ranges), it's quite effective. +profiler-cli is a **promising tool** that successfully enables command-line profile investigation. The core workflow is solid, and for focused investigations (zoomed into specific time ranges), it's quite effective. -However, **output verbosity and missing features** significantly limit its utility for complex investigations. Adding filtering, limiting, and marker viewing would transform pq from a "triage tool" into a "primary investigation tool." +However, **output verbosity and missing features** significantly limit its utility for complex investigations. Adding filtering, limiting, and marker viewing would transform profiler-cli from a "triage tool" into a "primary investigation tool." The handling of unsymbolicated code is **functional but needs UX work** - it's not a blocker, but better clarity would help users understand what they're looking at. -**Bottom line:** pq has excellent bones, but needs refinement to handle the scale and complexity of real-world performance profiles. +**Bottom line:** profiler-cli has excellent bones, but needs refinement to handle the scale and complexity of real-world performance profiles. **Cross-validation with Case Study 1:** The independent investigation reached nearly identical conclusions, confirming these findings are robust and actionable. diff --git a/pq-case-study.md b/profiler-cli-case-study.md similarity index 96% rename from pq-case-study.md rename to profiler-cli-case-study.md index eb648991a3..40aaf2ffee 100644 --- a/pq-case-study.md +++ b/profiler-cli-case-study.md @@ -1,4 +1,4 @@ -# Profile Query CLI (pq) - Case Study Report +# Profile Query CLI (profiler-cli) - Case Study Report ## Profile Investigation Summary @@ -29,7 +29,7 @@ The profile shows **bursty rendering activity** rather than sustained performanc --- -## pq Usability Evaluation +## profiler-cli Usability Evaluation ### What Worked Well @@ -223,7 +223,7 @@ No way to save investigation results. Had to pipe to `head` manually. Would want - Remembering timestamp names (ts-6, ts-7) is awkward - Have to remember which thread is selected -- No way to see "current state" - need `pq status` showing: +- No way to see "current state" - need `profiler-cli status` showing: - Current session - Selected thread - Current view range stack @@ -586,7 +586,7 @@ See **Design Recommendations** section above for detailed proposals on command s ### Comparison to Web UI -**pq Advantages:** +**profiler-cli Advantages:** - Much faster for quick triage - Easy to script/automate @@ -603,7 +603,7 @@ See **Design Recommendations** section above for detailed proposals on command s - Source view integration - Network panel, memory tracks, etc. -**Ideal Use Cases for pq:** +**Ideal Use Cases for profiler-cli:** - Quick "what's slow?" triage - CI/CD performance monitoring @@ -611,7 +611,7 @@ See **Design Recommendations** section above for detailed proposals on command s - Server-side investigation (no GUI) - Extracting specific data for reports -**Where pq Falls Short:** +**Where profiler-cli Falls Short:** - Complex multi-thread timing issues - Understanding frame scheduling @@ -625,7 +625,7 @@ See **Design Recommendations** section above for detailed proposals on command s **Current State: Promising Foundation (60% there)** -pq successfully demonstrates that CLI profiling is viable and valuable. The core architecture (daemon model, thread selection, view ranges) is sound. For profiles where you can identify which libraries are consuming time and single-threaded bottlenecks, it works reasonably well. +profiler-cli successfully demonstrates that CLI profiling is viable and valuable. The core architecture (daemon model, thread selection, view ranges) is sound. For profiles where you can identify which libraries are consuming time and single-threaded bottlenecks, it works reasonably well. **Critical Gaps:** @@ -637,7 +637,7 @@ pq successfully demonstrates that CLI profiling is viable and valuable. The core Fix the three critical gaps above before adding new features. A tool that shows incomplete information (truncated trees, missing module context, no markers) frustrates users more than missing features. **Potential:** -If library names, depth, and markers are addressed, pq could become the standard first-response tool for performance issues. "Run pq first, open web UI if needed" would be a great workflow. +If library names, depth, and markers are addressed, profiler-cli could become the standard first-response tool for performance issues. "Run profiler-cli first, open web UI if needed" would be a great workflow. **Estimated to "Production Ready":** diff --git a/pq-filters-and-bookmarks-proposal.md b/profiler-cli-filters-and-bookmarks-proposal.md similarity index 69% rename from pq-filters-and-bookmarks-proposal.md rename to profiler-cli-filters-and-bookmarks-proposal.md index 70b9fc22b1..31bc5ba5da 100644 --- a/pq-filters-and-bookmarks-proposal.md +++ b/profiler-cli-filters-and-bookmarks-proposal.md @@ -1,9 +1,9 @@ -# pq Filters and Bookmarks Proposal +# profiler-cli Filters and Bookmarks Proposal **Status:** Phase 1 + Phase 2 implemented; Phase 3 (strip-prefix, strip-suffix, merge-regex) and marker display filters and bookmarks are future work **Created:** 2025-01-04 **Last Updated:** 2026-04-11 -**Related:** pq-todo.md +**Related:** profiler-cli-todo.md --- @@ -28,7 +28,7 @@ Based on feedback, this proposal includes: ## Overview -This proposal defines a comprehensive system for managing analysis state in pq with **four independent dimensions**: +This proposal defines a comprehensive system for managing analysis state in profiler-cli with **four independent dimensions**: 1. **Thread selection** (global) - which thread(s) you're analyzing 2. **Zoom** (global) - time range you're focused on @@ -47,7 +47,7 @@ Each dimension supports: 1. **Ephemeral by default** - All commands accept filter flags that apply only to that invocation 2. **Explicit stickiness** - Making state sticky requires explicit commands (`select`, `zoom push`, `filter push`) -3. **Clear state** - `pq status` always shows current thread, zoom, and active filters +3. **Clear state** - `profiler-cli status` always shows current thread, zoom, and active filters 4. **Composable** - Filters, zoom, and thread selection are independent dimensions 5. **Saveable** - Complex views can be bookmarked and recalled ⏳ future @@ -63,69 +63,69 @@ invocation and do not affect the sticky filter stack. ```bash # Ephemeral sample inclusion/exclusion -pq thread samples --includes-function f-142 -pq thread samples --includes-any-function f-142,f-143 -pq thread samples --includes-prefix f-100,f-101,f-142 -pq thread samples --includes-suffix f-142 -pq thread samples --excludes-function f-142 -pq thread samples --excludes-any-function f-142,f-143 +profiler-cli thread samples --includes-function f-142 +profiler-cli thread samples --includes-any-function f-142,f-143 +profiler-cli thread samples --includes-prefix f-100,f-101,f-142 +profiler-cli thread samples --includes-suffix f-142 +profiler-cli thread samples --excludes-function f-142 +profiler-cli thread samples --excludes-any-function f-142,f-143 # Ephemeral marker-based filters -pq thread samples --during-marker --search Paint -pq thread samples --outside-marker --search GC +profiler-cli thread samples --during-marker --search Paint +profiler-cli thread samples --outside-marker --search GC # Ephemeral stack transforms -pq thread samples --merge f-142,f-143 -pq thread samples --root-at f-142 +profiler-cli thread samples --merge f-142,f-143 +profiler-cli thread samples --root-at f-142 # Multiple ephemeral filters on one command — applied in left-to-right order -pq thread samples --excludes-function f-142 --merge f-143 --during-marker --search Paint +profiler-cli thread samples --excludes-function f-142 --merge f-143 --during-marker --search Paint # Ephemeral and sticky filters compose: # sticky filters are applied first (already in the Redux transform stack), # then ephemeral filters are layered on top for that one invocation. -pq thread functions --limit 20 --excludes-function f-142 +profiler-cli thread functions --limit 20 --excludes-function f-142 ``` **Not yet implemented for ephemeral use:** -- `--zoom ` on thread commands (use `pq zoom push` instead) -- `--threads t-0,t-93` on thread commands (use `pq thread select` instead) +- `--zoom ` on thread commands (use `profiler-cli zoom push` instead) +- `--threads t-0,t-93` on thread commands (use `profiler-cli thread select` instead) - `--strip-prefix`, `--strip-suffix`, `--merge-regex` (Phase 3, see below) - `--during-marker --category` (only `--search` is supported) ### 2. Sticky Thread Selection ✅ implemented ```bash -pq thread select t-93 # Select single thread (sticky) -pq thread samples # Uses t-93 +profiler-cli thread select t-93 # Select single thread (sticky) +profiler-cli thread samples # Uses t-93 -pq thread select t-0,t-93 # Select multiple threads (sticky) -pq thread samples # Uses both threads +profiler-cli thread select t-0,t-93 # Select multiple threads (sticky) +profiler-cli thread samples # Uses both threads -pq thread select t-0 # Switch to different thread +profiler-cli thread select t-0 # Switch to different thread ``` ### 3. Sticky Zoom (Stack-based) ✅ implemented ```bash -pq zoom push ts-6,ts-7 # Push zoom level -pq thread samples # Uses zoomed range +profiler-cli zoom push ts-6,ts-7 # Push zoom level +profiler-cli thread samples # Uses zoomed range -pq zoom push ts-6a,ts-6c # Zoom further (within previous range) -pq thread samples # Uses nested zoom +profiler-cli zoom push ts-6a,ts-6c # Zoom further (within previous range) +profiler-cli thread samples # Uses nested zoom -pq zoom pop # Pop one zoom level (back to ts-6,ts-7) -pq zoom pop # Pop again (back to full profile) +profiler-cli zoom pop # Pop one zoom level (back to ts-6,ts-7) +profiler-cli zoom pop # Pop again (back to full profile) -pq zoom clear # Clear entire zoom stack +profiler-cli zoom clear # Clear entire zoom stack -pq zoom push m-158 # Zoom to marker's time range +profiler-cli zoom push m-158 # Zoom to marker's time range ``` ### 4. Sticky Sample/Stack Filters (Per-Thread) ✅ implemented -Each thread has its own filter stack. `pq filter push` appends a filter that +Each thread has its own filter stack. `profiler-cli filter push` appends a filter that persists across all subsequent analysis commands until popped. Function handles (`f-N`) identify index N in the shared funcTable — they are @@ -133,43 +133,43 @@ stable across sessions for the same profile. ```bash # Select thread first -pq thread select t-93 +profiler-cli thread select t-93 # Push filters onto THIS THREAD's stack (applied in push order) # Include/exclude samples -pq filter push --includes-function f-142 # keep samples whose stack contains f-142 -pq filter push --includes-any-function f-142,f-143 # keep samples with f-142 OR f-143 -pq filter push --includes-prefix f-100,f-200 # keep samples whose stack starts f-100→f-200 -pq filter push --includes-suffix f-142 # keep samples whose leaf frame is f-142 -pq filter push --excludes-function f-142 # drop samples containing f-142 -pq filter push --excludes-any-function f-142,f-143 # drop samples containing f-142 or f-143 -pq filter push --during-marker --search Paint # keep samples during Paint markers -pq filter push --outside-marker --search GC # keep samples outside GC markers +profiler-cli filter push --includes-function f-142 # keep samples whose stack contains f-142 +profiler-cli filter push --includes-any-function f-142,f-143 # keep samples with f-142 OR f-143 +profiler-cli filter push --includes-prefix f-100,f-200 # keep samples whose stack starts f-100→f-200 +profiler-cli filter push --includes-suffix f-142 # keep samples whose leaf frame is f-142 +profiler-cli filter push --excludes-function f-142 # drop samples containing f-142 +profiler-cli filter push --excludes-any-function f-142,f-143 # drop samples containing f-142 or f-143 +profiler-cli filter push --during-marker --search Paint # keep samples during Paint markers +profiler-cli filter push --outside-marker --search GC # keep samples outside GC markers # Stack transforms -pq filter push --merge f-142,f-143,f-144 # collapse these functions out of stacks -pq filter push --root-at f-142 # re-root all stacks at f-142 +profiler-cli filter push --merge f-142,f-143,f-144 # collapse these functions out of stacks +profiler-cli filter push --root-at f-142 # re-root all stacks at f-142 # Order matters — each filter sees stacks as left by the previous filter: -pq filter push --root-at f-100 # 1. re-root at f-100 -pq filter push --includes-prefix f-100 # 2. keep only stacks starting with f-100 +profiler-cli filter push --root-at f-100 # 1. re-root at f-100 +profiler-cli filter push --includes-prefix f-100 # 2. keep only stacks starting with f-100 # Filter 2 operates on stacks already transformed by filter 1 # Management -pq filter list # Show filters for current thread -pq filter pop # Pop most recent filter -pq filter pop 3 # Pop 3 most recent filters -pq filter clear # Clear all filters for current thread +profiler-cli filter list # Show filters for current thread +profiler-cli filter pop # Pop most recent filter +profiler-cli filter pop 3 # Pop 3 most recent filters +profiler-cli filter clear # Clear all filters for current thread # Switch threads — different filter stack! -pq thread select t-0 -pq filter list # t-0's filters (independent from t-93) +profiler-cli thread select t-0 +profiler-cli filter list # t-0's filters (independent from t-93) # All analysis commands use current thread's filters automatically -pq thread samples -pq thread functions -pq thread samples-top-down +profiler-cli thread samples +profiler-cli thread functions +profiler-cli thread samples-top-down ``` **Not yet implemented (Phase 3 / deferred):** @@ -181,15 +181,15 @@ pq thread samples-top-down ### 5. Sticky Marker Filters (Per-Thread) ⏳ not yet implemented Planned: each thread will also have its own marker filter stack controlling -which markers appear in `pq thread markers` output. +which markers appear in `profiler-cli thread markers` output. ```bash # Future syntax (not yet implemented) -pq marker filter push --search Paint -pq marker filter push --category Graphics -pq marker filter push --min-duration 5 -pq marker filter pop -pq marker filter clear +profiler-cli marker filter push --search Paint +profiler-cli marker filter push --category Graphics +profiler-cli marker filter push --min-duration 5 +profiler-cli marker filter pop +profiler-cli marker filter clear ``` Note: sample filters that reference markers (`--during-marker`, `--outside-marker`) @@ -202,12 +202,12 @@ Planned: save and restore complex views (zoom + thread selection, or filter stac ```bash # Future syntax (not yet implemented) -pq bookmark view spike1 --zoom ts-6,ts-7 --threads t-0,t-93 -pq bookmark filter no-allocators --merge f-142,f-143 -pq thread samples --view spike1 # ephemeral bookmark use -pq bookmark load view spike1 # sticky bookmark use -pq bookmark list -pq bookmark delete spike1 +profiler-cli bookmark view spike1 --zoom ts-6,ts-7 --threads t-0,t-93 +profiler-cli bookmark filter no-allocators --merge f-142,f-143 +profiler-cli thread samples --view spike1 # ephemeral bookmark use +profiler-cli bookmark load view spike1 # sticky bookmark use +profiler-cli bookmark list +profiler-cli bookmark delete spike1 ``` ### 7. Status Command ✅ implemented @@ -215,7 +215,7 @@ pq bookmark delete spike1 Shows current thread, zoom stack, and all active filter stacks: ```bash -pq status +profiler-cli status # Example output: Session Status: @@ -252,8 +252,8 @@ Control which samples are included in analysis. All take `f-N` function handles. --outside-marker --search GC # timestamp outside matching markers # AND semantics via repeated push: -pq filter push --includes-function f-1 # keep samples with f-1 -pq filter push --includes-function f-2 # AND also with f-2 +profiler-cli filter push --includes-function f-1 # keep samples with f-1 +profiler-cli filter push --includes-function f-2 # AND also with f-2 ``` **Not yet implemented:** @@ -286,86 +286,86 @@ Modify the structure of stacks before or after sample filtering. ```bash # See profile overview -$ pq profile info +$ profiler-cli profile info # Output shows a spike around ts-6,ts-7 # Quick peek at that spike -$ pq zoom push ts-6,ts-7 -$ pq thread samples --limit 20 +$ profiler-cli zoom push ts-6,ts-7 +$ profiler-cli thread samples --limit 20 # Check what's happening in the Renderer thread during that time -$ pq thread samples --thread t-26 --limit 20 +$ profiler-cli thread samples --thread t-26 --limit 20 # Make thread selection sticky and investigate further -$ pq thread select t-26 -$ pq thread samples +$ profiler-cli thread select t-26 +$ profiler-cli thread samples # Done with spike -$ pq zoom pop +$ profiler-cli zoom pop ``` ### Scenario 2: Eliminating Allocator Noise ```bash # Call tree shows allocator noise -$ pq thread functions --search malloc +$ profiler-cli thread functions --search malloc # → f-142. libmalloc!malloc_zone_malloc - self: 8.2% # Try merging ephemerally first -$ pq thread samples --merge f-142,f-143 --limit 30 +$ profiler-cli thread samples --merge f-142,f-143 --limit 30 # Better! See actual work # Make it sticky -$ pq filter push --merge f-142,f-143 -$ pq thread samples +$ profiler-cli filter push --merge f-142,f-143 +$ profiler-cli thread samples # Clean call tree across all subsequent commands # Remove when done -$ pq filter clear +$ profiler-cli filter clear ``` ### Scenario 3: Analyzing Time in a Specific Function ```bash # Find an expensive function -$ pq thread functions --search PresentImpl +$ profiler-cli thread functions --search PresentImpl # → f-500. XUL!CDXGISwapChain::PresentImpl - self: 16.4% # Ephemeral: see the subtree rooted at it -$ pq thread samples --root-at f-500 --limit 30 +$ profiler-cli thread samples --root-at f-500 --limit 30 # Sticky: focus all analysis on samples containing it -$ pq filter push --includes-function f-500 -$ pq filter push --root-at f-500 -$ pq thread samples -$ pq thread functions +$ profiler-cli filter push --includes-function f-500 +$ profiler-cli filter push --root-at f-500 +$ profiler-cli thread samples +$ profiler-cli thread functions # Clear when done -$ pq filter clear +$ profiler-cli filter clear ``` ### Scenario 4: Cross-Thread Causality ```bash # Look at main thread markers -$ pq thread select t-0 -$ pq marker info m-158 +$ profiler-cli thread select t-0 +$ profiler-cli marker info m-158 # WindowProc WM_PAINT at ... (33.52ms) # Zoom to that marker -$ pq zoom push m-158 +$ profiler-cli zoom push m-158 # See what the Renderer thread was doing during the same window -$ pq thread samples --thread t-26 --limit 20 +$ profiler-cli thread samples --thread t-26 --limit 20 # Make it sticky and drill further -$ pq thread select t-26 -$ pq filter push --during-marker --search Paint -$ pq thread samples +$ profiler-cli thread select t-26 +$ profiler-cli filter push --during-marker --search Paint +$ profiler-cli thread samples # Now only see Renderer work that happened during Paint markers -$ pq filter clear +$ profiler-cli filter clear -$ pq zoom pop +$ profiler-cli zoom pop ``` --- @@ -404,7 +404,7 @@ New `FilterSamplesType` values in `src/types/transforms.ts` / `src/profile-logic - **`ProfileQuerier`** — `filterPush/Pop/List/Clear` for sticky filters; `_withEphemeralFilters()` for one-shot use; filter state in `getStatus()` - Sticky filters live in the Redux transform stack and are automatically applied by all analysis commands via `getFilteredThread()` - Ephemeral filters are pushed on top of sticky ones for the duration of one call, then popped -- `outside-marker`, `function-include`, `stack-prefix`, `stack-suffix` are pq-only and not URL-serializable +- `outside-marker`, `function-include`, `stack-prefix`, `stack-suffix` are profiler-cli-only and not URL-serializable --- @@ -417,11 +417,11 @@ New `FilterSamplesType` values in `src/types/transforms.ts` / `src/profile-logic Function handles themselves are global (index into `profile.shared.funcTable`), so `f-142` means the same function regardless of which thread's filter stack it is pushed onto. ```bash -pq thread select t-0 -pq filter push --includes-function f-142 # on t-0's transform stack +profiler-cli thread select t-0 +profiler-cli filter push --includes-function f-142 # on t-0's transform stack -pq thread select t-26 -pq filter list # t-26's stack (empty — independent from t-0) +profiler-cli thread select t-26 +profiler-cli filter list # t-26's stack (empty — independent from t-0) ``` ### Filter Application Order @@ -431,15 +431,15 @@ as transformed by all previous filters, the order determines the semantics: ```bash # Root-at first, then include-prefix: the prefix check runs on re-rooted stacks -pq filter push --root-at f-100 -pq filter push --includes-prefix f-100 +profiler-cli filter push --root-at f-100 +profiler-cli filter push --includes-prefix f-100 # Merge first, then include: only samples where f-142 appears AFTER merging allocators -pq filter push --merge f-300,f-301 -pq filter push --includes-function f-142 +profiler-cli filter push --merge f-300,f-301 +profiler-cli filter push --includes-function f-142 ``` -### Actual `pq status` Output Format +### Actual `profiler-cli status` Output Format ``` Session Status: @@ -471,7 +471,7 @@ Filter type labels in output match the `SampleFilterSpec.type` field: ## Summary -This proposal creates a consistent, composable system for managing analysis state in pq: +This proposal creates a consistent, composable system for managing analysis state in profiler-cli: - **Ephemeral filters** via flags on any thread analysis command — not persisted ✅ - **Sticky filters** via `filter push/pop/clear` — persists across commands ✅ diff --git a/marker-support-plan.md b/profiler-cli-marker-support-plan.md similarity index 86% rename from marker-support-plan.md rename to profiler-cli-marker-support-plan.md index 2f68a0c6c5..d03d0ef633 100644 --- a/marker-support-plan.md +++ b/profiler-cli-marker-support-plan.md @@ -1,8 +1,8 @@ -# Marker Support Implementation Plan for `pq` CLI +# Marker Support Implementation Plan for `profiler-cli` CLI ## Overview -This document outlines the implementation plan for adding comprehensive marker support to the profile-query CLI (`pq`). Markers provide ~50% of profiling insight (Layout/Reflow, JavaScript names, IPC messages, GPU boundaries) and are a critical missing feature. +This document outlines the implementation plan for adding comprehensive marker support to the profile-query CLI (`profiler-cli`). Markers provide ~50% of profiling insight (Layout/Reflow, JavaScript names, IPC messages, GPU boundaries) and are a critical missing feature. ## Background @@ -57,12 +57,12 @@ We cannot naively print all markers and their fields to the CLI because: **Commands:** ```bash -pq thread markers # List marker groups with counts -pq thread markers --summary # Show aggregate statistics -pq marker info # Show details for a specific marker +profiler-cli thread markers # List marker groups with counts +profiler-cli thread markers --summary # Show aggregate statistics +profiler-cli marker info # Show details for a specific marker ``` -**`pq thread markers` output:** +**`profiler-cli thread markers` output:** ``` Markers in thread t-93 (Renderer) — 14,523 markers @@ -92,7 +92,7 @@ Rate Analysis (markers/second): Use --type to filter, --details for per-marker info, or m- handles to inspect individual markers. ``` -**`pq thread markers --summary` output:** +**`profiler-cli thread markers --summary` output:** ``` Marker Summary for thread t-93 (Renderer) @@ -199,25 +199,25 @@ Phase 2 provides both text-based search and duration-based filtering: **Commands:** ```bash -pq thread markers --search DOMEvent # Search for "DOMEvent" markers -pq thread markers --search Stack # Search for markers with "Stack" in name -pq thread markers --category Graphics # Filter by Graphics category -pq thread markers --category GC # Partial match: matches "GC / CC" -pq thread markers --has-stack # Only markers with stack traces -pq thread markers --min-duration 10 # Markers with duration >= 10ms -pq thread markers --max-duration 100 # Markers with duration <= 100ms -pq thread markers --limit 1000 # Limit to first 1000 markers -pq thread markers --min-duration 5 --max-duration 50 # Markers between 5-50ms -pq thread markers --category Other --min-duration 10 # Combined category and duration -pq thread markers --has-stack --category Layout --min-duration 1 # All filters combined -pq thread markers --category Layout --limit 50 # Limit after filtering -pq thread markers --search Reflow --min-duration 5 # Combined search and duration +profiler-cli thread markers --search DOMEvent # Search for "DOMEvent" markers +profiler-cli thread markers --search Stack # Search for markers with "Stack" in name +profiler-cli thread markers --category Graphics # Filter by Graphics category +profiler-cli thread markers --category GC # Partial match: matches "GC / CC" +profiler-cli thread markers --has-stack # Only markers with stack traces +profiler-cli thread markers --min-duration 10 # Markers with duration >= 10ms +profiler-cli thread markers --max-duration 100 # Markers with duration <= 100ms +profiler-cli thread markers --limit 1000 # Limit to first 1000 markers +profiler-cli thread markers --min-duration 5 --max-duration 50 # Markers between 5-50ms +profiler-cli thread markers --category Other --min-duration 10 # Combined category and duration +profiler-cli thread markers --has-stack --category Layout --min-duration 1 # All filters combined +profiler-cli thread markers --category Layout --limit 50 # Limit after filtering +profiler-cli thread markers --search Reflow --min-duration 5 # Combined search and duration ``` **Output example:** ```bash -$ pq thread markers --search DOMEvent +$ profiler-cli thread markers --search DOMEvent Markers in thread t-0 (Parent Process) — 2849 markers (filtered from 258060) @@ -313,17 +313,17 @@ Phase 3 provides multi-level marker grouping with both manual and automatic grou **Commands:** ```bash -pq thread markers --group-by type,name # Group by type, then name -pq thread markers --group-by type,field:eventType # Group by type, then eventType field -pq thread markers --group-by category,type # Group by category, then type -pq thread markers --auto-group # Automatic smart grouping -pq thread markers --search DOMEvent --group-by field:eventType # Filter + custom grouping +profiler-cli thread markers --group-by type,name # Group by type, then name +profiler-cli thread markers --group-by type,field:eventType # Group by type, then eventType field +profiler-cli thread markers --group-by category,type # Group by category, then type +profiler-cli thread markers --auto-group # Automatic smart grouping +profiler-cli thread markers --search DOMEvent --group-by field:eventType # Filter + custom grouping ``` **Output example:** ```bash -$ pq thread markers --search DOMEvent --auto-group --limit 200 +$ profiler-cli thread markers --search DOMEvent --auto-group --limit 200 Markers in thread t-0 (Parent Process) — 200 markers (filtered from 258060) @@ -359,14 +359,14 @@ Phase 4 provides comprehensive marker inspection with detailed field display and **Commands:** ```bash -pq marker info # Full marker details with stack preview -pq marker stack # Complete stack trace (all frames) +profiler-cli marker info # Full marker details with stack preview +profiler-cli marker stack # Complete stack trace (all frames) ``` **Actual output:** ```bash -$ pq thread markers --has-stack --limit 3 +$ profiler-cli thread markers --has-stack --limit 3 Markers in thread t-0 (Parent Process) — 3 markers (filtered from 258060) @@ -378,7 +378,7 @@ By Type (top 15): FlowMarker 1 markers (instant) Examples: m-3 -$ pq marker info m-1 +$ profiler-cli marker info m-1 Marker m-1: NotifyObservers - NotifyObservers @@ -399,9 +399,9 @@ Stack trace: [20] xul.dll!js::InternalCallOrConstruct(JSContext*, JS::CallArgs const&, js::) ... (101 more frames) -Use 'pq marker stack m-1' for the full stack trace. +Use 'profiler-cli marker stack m-1' for the full stack trace. -$ pq marker stack m-1 +$ profiler-cli marker stack m-1 Stack trace for marker m-1: NotifyObservers @@ -418,8 +418,8 @@ Captured at: 1h2m **Implementation details:** - **Marker handles visible**: Top 3 example markers shown for each type with handles and durations -- **`pq marker info`**: Shows full marker details with stack trace preview (first 20 frames) -- **`pq marker stack`**: Displays complete stack traces without frame limit +- **`profiler-cli marker info`**: Shows full marker details with stack trace preview (first 20 frames) +- **`profiler-cli marker stack`**: Displays complete stack traces without frame limit - **Stack formatting**: Reuses formatFunctionNameWithLibrary() for consistent display with library names - **MarkerSchema integration**: Fields formatted using existing MarkerSchema formatters from web UI @@ -435,7 +435,7 @@ Captured at: 1h2m **Future enhancements (not yet implemented):** -- [ ] `pq marker expand ` - Show full field values for truncated fields +- [ ] `profiler-cli marker expand ` - Show full field values for truncated fields - [ ] `--format json` option for machine-readable output ### Phase 5: Temporal Visualization (ASCII Charts) @@ -445,15 +445,15 @@ Captured at: 1h2m **Commands:** ```bash -pq thread markers --timeline # ASCII timeline -pq thread markers --type Reflow --timeline # Timeline for specific type -pq thread markers --histogram # Duration histogram +profiler-cli thread markers --timeline # ASCII timeline +profiler-cli thread markers --type Reflow --timeline # Timeline for specific type +profiler-cli thread markers --histogram # Duration histogram ``` **Output example:** ```bash -$ pq thread markers --type Reflow --timeline +$ profiler-cli thread markers --type Reflow --timeline Reflow markers timeline (thread t-93, 2.145s - 15.891s, 13.746s total) @@ -506,16 +506,16 @@ Peak activity: 8.123s - 8.456s (23 markers in 333ms window) **Commands:** ```bash -pq thread markers --rate-analysis # Analyze marker rate patterns -pq thread markers --type Network --waterfall # Network waterfall chart -pq thread markers --overlap-analysis # Find overlapping markers -pq thread markers --critical-path # Identify critical path markers +profiler-cli thread markers --rate-analysis # Analyze marker rate patterns +profiler-cli thread markers --type Network --waterfall # Network waterfall chart +profiler-cli thread markers --overlap-analysis # Find overlapping markers +profiler-cli thread markers --critical-path # Identify critical path markers ``` **Rate Analysis Output:** ```bash -$ pq thread markers --type DOMEvent --rate-analysis +$ profiler-cli thread markers --type DOMEvent --rate-analysis Rate analysis for DOMEvent markers (thread t-93) @@ -544,7 +544,7 @@ Idle periods (>1000ms without markers): **Network Waterfall Output:** ```bash -$ pq thread markers --type Network --waterfall +$ profiler-cli thread markers --type Network --waterfall Network waterfall (thread t-93, 50 requests) @@ -747,7 +747,7 @@ interface MarkerListOptions { ## Open Questions / Design Decisions 1. **Handle Persistence**: Should marker handles (m-N) be stable across sessions, or ephemeral? - - **Decision**: Ephemeral within session (like function handles), reset on each `pq load` + - **Decision**: Ephemeral within session (like function handles), reset on each `profiler-cli load` 2. **Default Grouping**: What should be the default grouping strategy? - **Decision**: Group by type first, with option to drill down diff --git a/pq-todo.md b/profiler-cli-todo.md similarity index 65% rename from pq-todo.md rename to profiler-cli-todo.md index ad9c6019e9..4836ae2587 100644 --- a/pq-todo.md +++ b/profiler-cli-todo.md @@ -1,12 +1,12 @@ -# pq To-Do List +# profiler-cli To-Do List -Feature wishlist and improvement ideas for the pq profile query tool. +Feature wishlist and improvement ideas for the profiler-cli profile query tool. --- ## Active Proposals -**[Filters and Bookmarks System](pq-filters-and-bookmarks-proposal.md)** - Comprehensive design for ephemeral vs sticky state, filter system, and bookmarks. +**[Filters and Bookmarks System](profiler-cli-filters-and-bookmarks-proposal.md)** - Comprehensive design for ephemeral vs sticky state, filter system, and bookmarks. --- @@ -27,10 +27,10 @@ Every command output displays current context in a compact header: Commands available: ```bash -pq thread functions # List all functions with CPU% -pq thread functions --search Present # Substring search -pq thread functions --min-self 1 # Filter by self time percentage -pq thread functions --limit 50 # Limit results +profiler-cli thread functions # List all functions with CPU% +profiler-cli thread functions --search Present # Substring search +profiler-cli thread functions --min-self 1 # Filter by self time percentage +profiler-cli thread functions --limit 50 # Limit results ``` ### 3. Smart Range Navigation ⚠️ @@ -40,24 +40,24 @@ pq thread functions --limit 50 # Limit results **Implemented:** ```bash -pq zoom push m-158 # Zoom to marker's time range +profiler-cli zoom push m-158 # Zoom to marker's time range ``` **Still needed:** ```bash -pq profile hotspots # List all high-CPU periods -pq zoom push --spike 1 # Jump to first spike -pq zoom push --spike next # Next spike from current position -pq profile hotspots --min-cpu 150% # Find sustained >150% periods +profiler-cli profile hotspots # List all high-CPU periods +profiler-cli zoom push --spike 1 # Jump to first spike +profiler-cli zoom push --spike next # Next spike from current position +profiler-cli profile hotspots --min-cpu 150% # Find sustained >150% periods ``` **Enhancement:** Named bookmarks: ```bash -pq zoom push ts-Fo,ts-Fu --name "resize-thrash" -pq zoom list -pq zoom push resize-thrash +profiler-cli zoom push ts-Fo,ts-Fu --name "resize-thrash" +profiler-cli zoom list +profiler-cli zoom push resize-thrash ``` ### 4. Cross-Thread Marker View ✅ @@ -65,16 +65,16 @@ pq zoom push resize-thrash Commands available: ```bash -pq thread markers --thread t-0,t-93 # Merged view of specific threads -pq thread functions --thread t-0,t-93 # Functions from multiple threads -pq thread samples --thread t-0,t-93 # Samples from multiple threads -pq thread select t-0,t-93 # Select threads (sticky) +profiler-cli thread markers --thread t-0,t-93 # Merged view of specific threads +profiler-cli thread functions --thread t-0,t-93 # Functions from multiple threads +profiler-cli thread samples --thread t-0,t-93 # Samples from multiple threads +profiler-cli thread select t-0,t-93 # Select threads (sticky) ``` **Not yet implemented:** ```bash -pq marker related m-158 # Show markers on other threads at same time +profiler-cli marker related m-158 # Show markers on other threads at same time ``` --- @@ -88,13 +88,13 @@ pq marker related m-158 # Show markers on other threads at same **Proposed:** ```bash -pq marker info m-@1 # First marker in last listing -pq marker info m-@last # Last marker in last listing -pq marker info m-@longest # Longest duration in last listing -pq marker info m-@prev # Previously inspected marker +profiler-cli marker info m-@1 # First marker in last listing +profiler-cli marker info m-@last # Last marker in last listing +profiler-cli marker info m-@longest # Longest duration in last listing +profiler-cli marker info m-@prev # Previously inspected marker -pq function expand f-@1 # First function in last listing -pq function expand f-@highest # Highest self-time +profiler-cli function expand f-@1 # First function in last listing +profiler-cli function expand f-@highest # Highest self-time ``` **Alternative:** Show rank in listings: @@ -105,7 +105,7 @@ Markers in thread t-0 — 50 markers #2 → m-148 (15.23ms) Runnable ``` -Then allow: `pq marker info #1` or `pq marker info @1` +Then allow: `profiler-cli marker info #1` or `profiler-cli marker info @1` ### 6. Stack Availability Indicators ✅ @@ -127,7 +127,7 @@ By Name (top 15): Command available: ```bash -pq thread samples-bottom-up # Bottom-up call tree +profiler-cli thread samples-bottom-up # Bottom-up call tree ``` Shows inverted call tree starting from leaf functions, directly answering "what code paths lead to this bottleneck?" @@ -139,11 +139,11 @@ Shows inverted call tree starting from leaf functions, directly answering "what **Flags needed:** ```bash -pq thread samples --limit 30 # Top 30 functions only -pq thread samples --min-self 1% # Hide functions <1% self time -pq thread samples --max-depth 15 # Limit tree depth -pq thread samples --top-only # Skip call trees -pq thread samples --tree-only # Skip top functions list +profiler-cli thread samples --limit 30 # Top 30 functions only +profiler-cli thread samples --min-self 1% # Hide functions <1% self time +profiler-cli thread samples --max-depth 15 # Limit tree depth +profiler-cli thread samples --top-only # Skip call trees +profiler-cli thread samples --tree-only # Skip top functions list ``` **Enhancement:** Show truncation stats when call tree is cut off: @@ -179,14 +179,14 @@ Functions (by self time): **Proposed:** ```bash -pq thread samples --thread t-93 # Query without selecting -pq thread info --thread t-0 # Peek at thread -pq thread markers --thread t-93 --search Paint +profiler-cli thread samples --thread t-93 # Query without selecting +profiler-cli thread info --thread t-0 # Peek at thread +profiler-cli thread markers --thread t-93 --search Paint ``` ### 11. Function Info Shows Full Name ✅ -The `pq function info` command displays both full and short names: +The `profiler-cli function info` command displays both full and short names: Note: Function info is resolved from `profile.shared.funcTable`, so the handle is profile-global rather than thread-local. @@ -220,8 +220,8 @@ Module Summary: **Proposed:** ```bash -pq summary # Overall profile summary -pq thread summary # Current thread(s) summary +profiler-cli summary # Overall profile summary +profiler-cli thread summary # Current thread(s) summary ``` Example output: @@ -253,7 +253,7 @@ Activity: Timestamp names show actual times inline: ```bash -pq profile info +profiler-cli profile info CPU activity over time: - 100% for 390.6ms: [ts-6 → ts-7] (2.701s - 3.092s) @@ -266,11 +266,11 @@ CPU activity over time: Zoom output shows duration, depth, and marker context: ```bash -pq zoom push ts-i,ts-M +profiler-cli zoom push ts-i,ts-M Pushed view range: ts-i → ts-M (6.991s - 10.558s, duration 3.567s) ts-i: Start of CPU spike #3 (100% CPU sustained) ts-M: End of marker m-143 (Composite frame) - Zoom depth: 2/5 (use "pq zoom pop" to go back) + Zoom depth: 2/5 (use "profiler-cli zoom pop" to go back) ``` ### 16. Range Comparison ❌ @@ -278,7 +278,7 @@ Pushed view range: ts-i → ts-M (6.991s - 10.558s, duration 3.567s) **Proposed:** ```bash -pq compare ts-6,ts-7 vs ts-8,ts-9 # Compare two ranges +profiler-cli compare ts-6,ts-7 vs ts-8,ts-9 # Compare two ranges # Example output: Comparison: [ts-6,ts-7] (391ms) vs [ts-8,ts-9] (255ms) @@ -297,9 +297,9 @@ Top Functions: **Proposed:** ```bash -pq thread waits # Show all wait operations -pq thread waits --min-duration 10ms # Significant waits only -pq thread waits --summary # Aggregate stats +profiler-cli thread waits # Show all wait operations +profiler-cli thread waits --min-duration 10ms # Significant waits only +profiler-cli thread waits --summary # Aggregate stats ``` ### 18. Frame-Level Analysis ❌ @@ -307,9 +307,9 @@ pq thread waits --summary # Aggregate stats **Proposed:** ```bash -pq thread frames # List paint/composite frames -pq thread frames --slow # Frames >16ms (jank) -pq frame info 72 # Details about frame #72 +profiler-cli thread frames # List paint/composite frames +profiler-cli thread frames --slow # Frames >16ms (jank) +profiler-cli frame info 72 # Details about frame #72 ``` ### 19. Stack-Level Inspection ❌ @@ -317,8 +317,8 @@ pq frame info 72 # Details about frame #72 **Proposed:** ```bash -pq thread stacks # Show heaviest individual stacks -pq thread stacks --limit 5 # Top 5 heaviest +profiler-cli thread stacks # Show heaviest individual stacks +profiler-cli thread stacks --limit 5 # Top 5 heaviest ``` ### 20. Split Sample Command ⚠️ @@ -326,9 +326,9 @@ pq thread stacks --limit 5 # Top 5 heaviest **Proposed:** ```bash -pq thread samples-top [--limit N] # Just top functions -pq thread samples-tree [--max-depth N] # Just call tree -pq thread samples # All views (backward compat) +profiler-cli thread samples-top [--limit N] # Just top functions +profiler-cli thread samples-tree [--max-depth N] # Just call tree +profiler-cli thread samples # All views (backward compat) ``` Note: `samples-bottom-up` is already a separate command (item #7). @@ -338,14 +338,14 @@ Note: `samples-bottom-up` is already a separate command (item #7). **Proposed:** ```bash -pq profile info --in-zoom # Show top threads/CPU in current zoom range -pq marker related m-158 # Show markers on other threads at same time +profiler-cli profile info --in-zoom # Show top threads/CPU in current zoom range +profiler-cli marker related m-158 # Show markers on other threads at same time ``` Example: ``` -$ pq marker related m-168 +$ profiler-cli marker related m-168 Marker m-168: Reflow (interruptible) at 3717465.724ms Related markers (±10ms window): @@ -394,9 +394,9 @@ The first number is frequency (markers/sec), and the min/avg/max values are inte **Proposed:** ```bash -pq thread samples --output report.txt # Save to file -pq thread markers --json > markers.json # JSON already works -pq session export investigation.pqsession # Save entire session state +profiler-cli thread samples --output report.txt # Save to file +profiler-cli thread markers --json > markers.json # JSON already works +profiler-cli session export investigation.pqsession # Save entire session state ``` ### 25. Color Output ❌ @@ -408,7 +408,7 @@ pq session export investigation.pqsession # Save entire session state **Proposed:** Show progress for operations >1s: ``` -$ pq load large-profile.json.gz +$ profiler-cli load large-profile.json.gz Loading profile... 45% (123MB/273MB) ``` @@ -435,14 +435,14 @@ Markers in thread t-0: **Proposed:** After commands, suggest related actions: ``` -$ pq marker info m-168 +$ profiler-cli marker info m-168 Marker m-168: Reflow (interruptible) - 907µs ... 💡 Next steps: - pq marker stack m-168 # View call stack - pq zoom push m-168 # Zoom to this marker's time range - pq thread markers --search Reflow --min-duration 500 # Find similar markers + profiler-cli marker stack m-168 # View call stack + profiler-cli zoom push m-168 # Zoom to this marker's time range + profiler-cli thread markers --search Reflow --min-duration 500 # Find similar markers ``` --- diff --git a/scripts/build-profile-query-cli.mjs b/scripts/build-profile-query-cli.mjs index 16d0b7054f..2e8c2fa2cf 100644 --- a/scripts/build-profile-query-cli.mjs +++ b/scripts/build-profile-query-cli.mjs @@ -11,7 +11,7 @@ const profileQueryCliConfig = { ...nodeBaseConfig, entryPoints: ['profile-query-cli/src/index.ts'], loader: { ...nodeBaseConfig.loader, '.txt': 'text' }, - outfile: 'profile-query-cli/dist/pq.js', + outfile: 'profile-query-cli/dist/profiler-cli.js', minify: true, banner: { js: '#!/usr/bin/env node\n\n// Polyfill browser globals for Node.js\nglobalThis.self = globalThis;', @@ -24,7 +24,7 @@ const profileQueryCliConfig = { async function build() { await esbuild.build(profileQueryCliConfig); - chmodSync('profile-query-cli/dist/pq.js', 0o755); + chmodSync('profile-query-cli/dist/profiler-cli.js', 0o755); console.log('✅ Profile-query-cli build completed'); } diff --git a/src/profile-logic/transforms.ts b/src/profile-logic/transforms.ts index 191608cfcd..c409b00c98 100644 --- a/src/profile-logic/transforms.ts +++ b/src/profile-logic/transforms.ts @@ -335,9 +335,9 @@ export function parseTransforms(transformString: string): TransformStack { const filterType = convertToFullFilterType(shortFilterType); if (filterType !== 'marker-search') { - // pq-only filter types are not supported in the frontend. + // profiler-cli-only filter types are not supported in the frontend. console.error( - 'A pq-only filter-samples type was found in the URL and will be ignored.', + 'A profiler-cli-only filter-samples type was found in the URL and will be ignored.', filterType ); break; @@ -364,7 +364,7 @@ function convertToFullFilterType(shortFilterType: string): FilterSamplesType { switch (shortFilterType) { case 'm': return 'marker-search'; - // pq-only types: + // profiler-cli-only types: case 'om': return 'outside-marker'; case 'fi': @@ -385,7 +385,7 @@ function convertToShortFilterType(filterType: FilterSamplesType): string { switch (filterType) { case 'marker-search': return 'm'; - // pq-only types: + // profiler-cli-only types: case 'outside-marker': return 'om'; case 'function-include': @@ -512,13 +512,13 @@ export function getTransformLabelL10nIds( 'TransformNavigator--drop-samples-outside-of-markers-matching', item: transform.filter, }; - // pq-only filter types: + // profiler-cli-only filter types: case 'outside-marker': case 'function-include': case 'stack-prefix': case 'stack-suffix': throw new Error( - `getTransformLabelL10nIds: pq-only filter type "${transform.filterType}" is not supported in the frontend transform navigator.` + `getTransformLabelL10nIds: profiler-cli-only filter type "${transform.filterType}" is not supported in the frontend transform navigator.` ); default: throw assertExhaustiveCheck(transform.filterType); diff --git a/src/profile-query/README.md b/src/profile-query/README.md index a70e7a1fe3..1923af6af6 100644 --- a/src/profile-query/README.md +++ b/src/profile-query/README.md @@ -4,7 +4,7 @@ A library for programmatically querying the contents of a Firefox Profiler profi ## Usage -**Note:** Most users should use the [profile-query-cli](../profile-query-cli/README.md) (`pq` command) instead of using this library directly. +**Note:** Most users should use the [profile-query-cli](../profile-query-cli/README.md) (`profiler-cli` command) instead of using this library directly. ### Building diff --git a/src/profile-query/filter-stack.ts b/src/profile-query/filter-stack.ts index 023884b19f..e4e26cf073 100644 --- a/src/profile-query/filter-stack.ts +++ b/src/profile-query/filter-stack.ts @@ -3,10 +3,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * FilterStack manages the per-thread sample filter stacks for the pq CLI. + * FilterStack manages the per-thread sample filter stacks for the profiler-cli CLI. * * Each thread (identified by its ThreadsKey) has an independent stack of filter - * entries. Each entry corresponds to one `pq filter push` invocation and may + * entries. Each entry corresponds to one `profiler-cli filter push` invocation and may * have dispatched one or more Redux transforms to the store. * * Popping entries removes both the in-memory records and the corresponding Redux diff --git a/src/profile-query/formatters/marker-info.ts b/src/profile-query/formatters/marker-info.ts index fa183d6efc..edee7d302f 100644 --- a/src/profile-query/formatters/marker-info.ts +++ b/src/profile-query/formatters/marker-info.ts @@ -1521,7 +1521,7 @@ export function formatMarkerInfo( if (stackLines.length > 21) { lines.push( - `\nUse 'pq marker stack ${markerHandle}' for the full stack trace.` + `\nUse 'profiler-cli marker stack ${markerHandle}' for the full stack trace.` ); } } diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index f34111566b..36e8431e29 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -46,8 +46,8 @@ export type FunctionFilterOptions = { // ===== Sample Filter Stack ===== /** - * The specification for a single entry on the pq filter stack. - * Each entry corresponds to one `pq filter push` invocation. + * The specification for a single entry on the profiler-cli filter stack. + * Each entry corresponds to one `profiler-cli filter push` invocation. */ export type SampleFilterSpec = // Phase 1: Redux transform-backed filters diff --git a/src/test/unit/profile-query/profile-querier.test.ts b/src/test/unit/profile-query/profile-querier.test.ts index 5337a429ab..185328c970 100644 --- a/src/test/unit/profile-query/profile-querier.test.ts +++ b/src/test/unit/profile-query/profile-querier.test.ts @@ -8,7 +8,7 @@ * NOTE: Currently minimal tests. * * The ProfileQuerier class is tested through integration tests in bash scripts - * (bin/pq-test) that load real profiles and verify the output. + * (bin/profiler-cli-test) that load real profiles and verify the output. * * Unit tests can be added here for specific utility methods or edge cases that * are easier to test in isolation. The summarize() method uses the diff --git a/src/types/transforms.ts b/src/types/transforms.ts index 01130ef869..28d9b3438b 100644 --- a/src/types/transforms.ts +++ b/src/types/transforms.ts @@ -35,7 +35,7 @@ import type { ImplementationFilter } from './actions'; * (encoded as a single funcIndex). * * Note: 'outside-marker', 'function-include', 'stack-prefix', and 'stack-suffix' are used - * by the pq CLI tool only and are not serialized to profile URLs. + * by the profiler-cli tool only and are not serialized to profile URLs. */ export type FilterSamplesType = | 'marker-search' From 47bfd1e4d6f9dd37a1571aae6f3a255cc204149f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Tue, 14 Apr 2026 18:43:05 +0200 Subject: [PATCH 41/63] Rename the directory to profiler-cli --- .prettierignore | 2 +- eslint.config.mjs | 4 ++-- jest.config.js | 8 +++---- package.json | 4 ++-- profiler-cli-case-study-2.md | 2 +- profiler-cli-case-study.md | 2 +- ...iler-cli-filters-and-bookmarks-proposal.md | 2 +- profiler-cli-marker-support-plan.md | 4 ++-- profiler-cli-todo.md | 2 +- .../.npmignore | 0 {profile-query-cli => profiler-cli}/README.md | 18 +++++++-------- {profile-query-cli => profiler-cli}/guide.txt | 0 .../package.json | 4 ++-- .../schemas.txt | 0 .../src/client.ts | 0 .../src/commands/filter.ts | 4 +++- .../src/commands/function.ts | 0 .../src/commands/marker.ts | 0 .../src/commands/profile.ts | 0 .../src/commands/session.ts | 0 .../src/commands/shared.ts | 0 .../src/commands/thread.ts | 4 +++- .../src/commands/zoom.ts | 0 .../src/constants.ts | 0 .../src/daemon.ts | 0 .../src/formatters.ts | 8 +++++-- .../src/index.ts | 7 +++--- .../src/output.ts | 0 .../src/protocol.ts | 0 .../src/session.ts | 0 .../src/test/integration/basic.test.ts | 0 .../test/integration/daemon-startup.test.ts | 0 .../src/test/integration/sessions.test.ts | 0 .../src/test/integration/setup.ts | 0 .../src/test/integration/utils.ts | 10 +++++--- .../call-tree-formatting.test.ts.snap | 0 .../test/unit/call-tree-formatting.test.ts | 0 .../src/test/unit/client.test.ts | 4 ++-- .../src/test/unit/daemon.test.ts | 4 ++-- .../src/test/unit/session.test.ts | 23 ++++++++++++++----- .../src/utils/parse.ts | 0 ...e-query-cli.mjs => build-profiler-cli.mjs} | 12 +++++----- src/profile-query/README.md | 4 ++-- src/profile-query/types.ts | 2 +- tsconfig.json | 2 +- 45 files changed, 79 insertions(+), 57 deletions(-) rename {profile-query-cli => profiler-cli}/.npmignore (100%) rename {profile-query-cli => profiler-cli}/README.md (96%) rename {profile-query-cli => profiler-cli}/guide.txt (100%) rename {profile-query-cli => profiler-cli}/package.json (88%) rename {profile-query-cli => profiler-cli}/schemas.txt (100%) rename {profile-query-cli => profiler-cli}/src/client.ts (100%) rename {profile-query-cli => profiler-cli}/src/commands/filter.ts (96%) rename {profile-query-cli => profiler-cli}/src/commands/function.ts (100%) rename {profile-query-cli => profiler-cli}/src/commands/marker.ts (100%) rename {profile-query-cli => profiler-cli}/src/commands/profile.ts (100%) rename {profile-query-cli => profiler-cli}/src/commands/session.ts (100%) rename {profile-query-cli => profiler-cli}/src/commands/shared.ts (100%) rename {profile-query-cli => profiler-cli}/src/commands/thread.ts (99%) rename {profile-query-cli => profiler-cli}/src/commands/zoom.ts (100%) rename {profile-query-cli => profiler-cli}/src/constants.ts (100%) rename {profile-query-cli => profiler-cli}/src/daemon.ts (100%) rename {profile-query-cli => profiler-cli}/src/formatters.ts (99%) rename {profile-query-cli => profiler-cli}/src/index.ts (96%) rename {profile-query-cli => profiler-cli}/src/output.ts (100%) rename {profile-query-cli => profiler-cli}/src/protocol.ts (100%) rename {profile-query-cli => profiler-cli}/src/session.ts (100%) rename {profile-query-cli => profiler-cli}/src/test/integration/basic.test.ts (100%) rename {profile-query-cli => profiler-cli}/src/test/integration/daemon-startup.test.ts (100%) rename {profile-query-cli => profiler-cli}/src/test/integration/sessions.test.ts (100%) rename {profile-query-cli => profiler-cli}/src/test/integration/setup.ts (100%) rename {profile-query-cli => profiler-cli}/src/test/integration/utils.ts (96%) rename {profile-query-cli => profiler-cli}/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap (100%) rename {profile-query-cli => profiler-cli}/src/test/unit/call-tree-formatting.test.ts (100%) rename {profile-query-cli => profiler-cli}/src/test/unit/client.test.ts (91%) rename {profile-query-cli => profiler-cli}/src/test/unit/daemon.test.ts (91%) rename {profile-query-cli => profiler-cli}/src/test/unit/session.test.ts (96%) rename {profile-query-cli => profiler-cli}/src/utils/parse.ts (100%) rename scripts/{build-profile-query-cli.mjs => build-profiler-cli.mjs} (72%) diff --git a/.prettierignore b/.prettierignore index a9d956a335..f4266c7c45 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,6 @@ src/profile-logic/import/proto src/types/libdef/npm -profile-query-cli/dist +profiler-cli/dist docs-user/js docs-user/css src/test/fixtures/upgrades diff --git a/eslint.config.mjs b/eslint.config.mjs index 771e893906..4f9eee5203 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,7 +18,7 @@ export default defineConfig( ignores: [ 'src/profile-logic/import/proto/**', 'src/types/libdef/npm/**', - 'profile-query-cli/dist/**', + 'profiler-cli/dist/**', 'res/**', 'dist/**', 'docs-user/**', @@ -253,7 +253,7 @@ export default defineConfig( // Test files override { - files: ['src/test/**/*', 'profile-query-cli/src/test/**/*'], + files: ['src/test/**/*', 'profiler-cli/src/test/**/*'], languageOptions: { globals: { ...globals.jest, diff --git a/jest.config.js b/jest.config.js index c36d63772f..36068e7f37 100644 --- a/jest.config.js +++ b/jest.config.js @@ -61,7 +61,7 @@ module.exports = { { ...browserEnvConfig, displayName: 'cli', - testMatch: ['/profile-query-cli/src/test/unit/**/*.test.ts'], + testMatch: ['/profiler-cli/src/test/unit/**/*.test.ts'], }, // ======================================================================== @@ -69,13 +69,11 @@ module.exports = { // ======================================================================== { displayName: 'cli-integration', - testMatch: [ - '/profile-query-cli/src/test/integration/**/*.test.ts', - ], + testMatch: ['/profiler-cli/src/test/integration/**/*.test.ts'], testEnvironment: 'node', - setupFilesAfterEnv: ['./profile-query-cli/src/test/integration/setup.ts'], + setupFilesAfterEnv: ['./profiler-cli/src/test/integration/setup.ts'], // Integration tests can be slow (loading profiles, spawning processes) testTimeout: 30000, diff --git a/package.json b/package.json index 1793723d1b..4856db18ce 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "build-symbolicator-cli:quiet": "yarn build-symbolicator-cli", "build-profile-query": "cross-env NODE_ENV=production node scripts/build-profile-query.mjs", "build-profile-query:quiet": "yarn build-profile-query", - "build-profile-query-cli": "cross-env NODE_ENV=production node scripts/build-profile-query-cli.mjs", + "build-profiler-cli": "cross-env NODE_ENV=production node scripts/build-profiler-cli.mjs", "lint": "node bin/output-fixing-commands.js run-p lint-js lint-css prettier-run", "lint-fix": "run-p lint-fix-js lint-fix-css prettier-fix", "lint-js": "node bin/output-fixing-commands.js eslint . --report-unused-disable-directives --cache --cache-strategy content", @@ -55,7 +55,7 @@ "test-alex": "alex ./docs-* CODE_OF_CONDUCT.md CONTRIBUTING.md README.md", "test-lockfile": "lockfile-lint --path yarn.lock --allowed-hosts yarn --validate-https", "test-debug": "cross-env LC_ALL=C TZ=UTC NODE_ENV=test node --inspect-brk node_modules/.bin/jest --runInBand", - "test-cli": "yarn build-profile-query-cli && node bin/output-fixing-commands.js cross-env LC_ALL=C TZ=UTC NODE_ENV=test jest --selectProjects=cli cli-integration", + "test-cli": "yarn build-profiler-cli && node bin/output-fixing-commands.js cross-env LC_ALL=C TZ=UTC NODE_ENV=test jest --selectProjects=cli cli-integration", "postinstall": "patch-package" }, "license": "MPL-2.0", diff --git a/profiler-cli-case-study-2.md b/profiler-cli-case-study-2.md index d9c75d35f3..141411abcf 100644 --- a/profiler-cli-case-study-2.md +++ b/profiler-cli-case-study-2.md @@ -1,4 +1,4 @@ -# profiler-cli Case Study 2: Investigating Repeated Rendering Spikes in Firefox +# Profiler CLI Case Study 2: Investigating Repeated Rendering Spikes in Firefox **Profile:** https://share.firefox.dev/4oLEjCw **Date:** November 4, 2025 diff --git a/profiler-cli-case-study.md b/profiler-cli-case-study.md index 40aaf2ffee..4bde5f02ec 100644 --- a/profiler-cli-case-study.md +++ b/profiler-cli-case-study.md @@ -1,4 +1,4 @@ -# Profile Query CLI (profiler-cli) - Case Study Report +# Profiler CLI - Case Study Report ## Profile Investigation Summary diff --git a/profiler-cli-filters-and-bookmarks-proposal.md b/profiler-cli-filters-and-bookmarks-proposal.md index 31bc5ba5da..f3cb652386 100644 --- a/profiler-cli-filters-and-bookmarks-proposal.md +++ b/profiler-cli-filters-and-bookmarks-proposal.md @@ -1,4 +1,4 @@ -# profiler-cli Filters and Bookmarks Proposal +# Profiler CLI Filters and Bookmarks Proposal **Status:** Phase 1 + Phase 2 implemented; Phase 3 (strip-prefix, strip-suffix, merge-regex) and marker display filters and bookmarks are future work **Created:** 2025-01-04 diff --git a/profiler-cli-marker-support-plan.md b/profiler-cli-marker-support-plan.md index d03d0ef633..f14994d66f 100644 --- a/profiler-cli-marker-support-plan.md +++ b/profiler-cli-marker-support-plan.md @@ -1,8 +1,8 @@ -# Marker Support Implementation Plan for `profiler-cli` CLI +# Marker Support Implementation Plan for Profiler CLI ## Overview -This document outlines the implementation plan for adding comprehensive marker support to the profile-query CLI (`profiler-cli`). Markers provide ~50% of profiling insight (Layout/Reflow, JavaScript names, IPC messages, GPU boundaries) and are a critical missing feature. +This document outlines the implementation plan for adding comprehensive marker support to profiler-cli. Markers provide ~50% of profiling insight (Layout/Reflow, JavaScript names, IPC messages, GPU boundaries) and are a critical missing feature. ## Background diff --git a/profiler-cli-todo.md b/profiler-cli-todo.md index 4836ae2587..8eb0386b7c 100644 --- a/profiler-cli-todo.md +++ b/profiler-cli-todo.md @@ -1,4 +1,4 @@ -# profiler-cli To-Do List +# Profiler CLI To-Do List Feature wishlist and improvement ideas for the profiler-cli profile query tool. diff --git a/profile-query-cli/.npmignore b/profiler-cli/.npmignore similarity index 100% rename from profile-query-cli/.npmignore rename to profiler-cli/.npmignore diff --git a/profile-query-cli/README.md b/profiler-cli/README.md similarity index 96% rename from profile-query-cli/README.md rename to profiler-cli/README.md index 0efd8083cc..678050ea90 100644 --- a/profile-query-cli/README.md +++ b/profiler-cli/README.md @@ -1,4 +1,4 @@ -# Profile Query CLI (`profiler-cli`) +# Profiler CLI A command-line interface for querying Firefox Profiler profiles with persistent daemon sessions. @@ -21,7 +21,7 @@ A command-line interface for querying Firefox Profiler profiles with persistent ```bash # Build the CLI -yarn build-profile-query-cli +yarn build-profiler-cli # Basic usage profiler-cli load # Start daemon and load profile (PATH can be a file or http/https URL) @@ -97,7 +97,7 @@ For `filter push`, exactly one flag per push. For ephemeral use, multiple flags ## Project Structure ``` -profile-query-cli/ +profiler-cli/ ├── src/ │ ├── index.ts # CLI entry point, argument parsing, command routing │ ├── client.ts # Client logic: spawn daemon, send commands via socket @@ -117,10 +117,10 @@ profile-query-cli/ This package uses a **bundled distribution approach**: -- **Source code**: Lives in `profile-query-cli/src/` within the firefox-devtools/profiler monorepo +- **Source code**: Lives in `profiler-cli/src/` within the firefox-devtools/profiler monorepo - **Dependencies**: Defined in the root `package.json` (react, redux, protobufjs, etc.) -- **Build process**: The CLI build writes a single ~640KB executable to `profile-query-cli/dist/profiler-cli.js` (~187KB gzipped) with zero runtime dependencies -- **Published artifact**: `profile-query-cli/dist/profiler-cli.js` is published to npm as `@firefox-profiler/profiler-cli` +- **Build process**: The CLI build writes a single ~640KB executable to `profiler-cli/dist/profiler-cli.js` (~187KB gzipped) with zero runtime dependencies +- **Published artifact**: `profiler-cli/dist/profiler-cli.js` is published to npm as `@firefox-profiler/profiler-cli` - **Package.json**: Contains only npm metadata - it does NOT list dependencies since they're pre-bundled This means: @@ -133,8 +133,8 @@ To publish: ```bash # From repository root -yarn build-profile-query-cli -cd profile-query-cli +yarn build-profiler-cli +cd profiler-cli npm publish ``` @@ -182,7 +182,7 @@ All test scripts automatically set `PROFILER_CLI_SESSION_DIR="./.profiler-cli-de **Build:** ```bash -yarn build-profile-query-cli # Creates ./dist/profiler-cli.js +yarn build-profiler-cli # Creates ./dist/profiler-cli.js ``` **Unit tests:** diff --git a/profile-query-cli/guide.txt b/profiler-cli/guide.txt similarity index 100% rename from profile-query-cli/guide.txt rename to profiler-cli/guide.txt diff --git a/profile-query-cli/package.json b/profiler-cli/package.json similarity index 88% rename from profile-query-cli/package.json rename to profiler-cli/package.json index 6b59e0f128..433e141674 100644 --- a/profile-query-cli/package.json +++ b/profiler-cli/package.json @@ -1,5 +1,5 @@ { - "_comment": "This is a distribution package for a pre-bundled CLI artifact. Build dependencies are defined in the root package.json. Run 'yarn build-profile-query-cli' from the repository root to build dist/profiler-cli.js.", + "_comment": "This is a distribution package for a pre-bundled CLI artifact. Build dependencies are defined in the root package.json. Run 'yarn build-profiler-cli' from the repository root to build dist/profiler-cli.js.", "name": "@firefox-profiler/profiler-cli", "version": "0.1.0", "description": "Command-line interface for querying Firefox Profiler profiles with persistent daemon sessions", @@ -32,7 +32,7 @@ "repository": { "type": "git", "url": "https://github.com/firefox-devtools/profiler", - "directory": "profile-query-cli" + "directory": "profiler-cli" }, "homepage": "https://profiler.firefox.com", "bugs": { diff --git a/profile-query-cli/schemas.txt b/profiler-cli/schemas.txt similarity index 100% rename from profile-query-cli/schemas.txt rename to profiler-cli/schemas.txt diff --git a/profile-query-cli/src/client.ts b/profiler-cli/src/client.ts similarity index 100% rename from profile-query-cli/src/client.ts rename to profiler-cli/src/client.ts diff --git a/profile-query-cli/src/commands/filter.ts b/profiler-cli/src/commands/filter.ts similarity index 96% rename from profile-query-cli/src/commands/filter.ts rename to profiler-cli/src/commands/filter.ts index 4fb4444472..e00d7f37c8 100644 --- a/profile-query-cli/src/commands/filter.ts +++ b/profiler-cli/src/commands/filter.ts @@ -88,7 +88,9 @@ export function registerFilterCommand( console.log(formatOutput(result, opts.json ?? false)); if (!wasExplicit('filter', 'list')) { - console.log('\nOther subcommands: profiler-cli filter [options]'); + console.log( + '\nOther subcommands: profiler-cli filter [options]' + ); } }); diff --git a/profile-query-cli/src/commands/function.ts b/profiler-cli/src/commands/function.ts similarity index 100% rename from profile-query-cli/src/commands/function.ts rename to profiler-cli/src/commands/function.ts diff --git a/profile-query-cli/src/commands/marker.ts b/profiler-cli/src/commands/marker.ts similarity index 100% rename from profile-query-cli/src/commands/marker.ts rename to profiler-cli/src/commands/marker.ts diff --git a/profile-query-cli/src/commands/profile.ts b/profiler-cli/src/commands/profile.ts similarity index 100% rename from profile-query-cli/src/commands/profile.ts rename to profiler-cli/src/commands/profile.ts diff --git a/profile-query-cli/src/commands/session.ts b/profiler-cli/src/commands/session.ts similarity index 100% rename from profile-query-cli/src/commands/session.ts rename to profiler-cli/src/commands/session.ts diff --git a/profile-query-cli/src/commands/shared.ts b/profiler-cli/src/commands/shared.ts similarity index 100% rename from profile-query-cli/src/commands/shared.ts rename to profiler-cli/src/commands/shared.ts diff --git a/profile-query-cli/src/commands/thread.ts b/profiler-cli/src/commands/thread.ts similarity index 99% rename from profile-query-cli/src/commands/thread.ts rename to profiler-cli/src/commands/thread.ts index ecae591e53..dcba519a82 100644 --- a/profile-query-cli/src/commands/thread.ts +++ b/profiler-cli/src/commands/thread.ts @@ -338,7 +338,9 @@ export function registerThreadCommand( opts.limit !== undefined ) { functionFilters = {}; - if (opts.search !== undefined) functionFilters.searchString = opts.search; + if (opts.search !== undefined) { + functionFilters.searchString = opts.search; + } if (opts.minSelf !== undefined) { const minSelf = parseFloat(opts.minSelf); if (isNaN(minSelf) || minSelf < 0 || minSelf > 100) { diff --git a/profile-query-cli/src/commands/zoom.ts b/profiler-cli/src/commands/zoom.ts similarity index 100% rename from profile-query-cli/src/commands/zoom.ts rename to profiler-cli/src/commands/zoom.ts diff --git a/profile-query-cli/src/constants.ts b/profiler-cli/src/constants.ts similarity index 100% rename from profile-query-cli/src/constants.ts rename to profiler-cli/src/constants.ts diff --git a/profile-query-cli/src/daemon.ts b/profiler-cli/src/daemon.ts similarity index 100% rename from profile-query-cli/src/daemon.ts rename to profiler-cli/src/daemon.ts diff --git a/profile-query-cli/src/formatters.ts b/profiler-cli/src/formatters.ts similarity index 99% rename from profile-query-cli/src/formatters.ts rename to profiler-cli/src/formatters.ts index a11fff6c69..9d767345be 100644 --- a/profile-query-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -105,7 +105,9 @@ export function formatStatusResult(result: StatusResult): string { const filterLines: string[] = []; for (const stack of result.filterStacks) { - if (stack.filters.length === 0) continue; + if (stack.filters.length === 0) { + continue; + } filterLines.push(` Filters for ${stack.threadHandle}:`); for (const f of stack.filters) { filterLines.push(` ${f.index}. [${f.spec.type}] ${f.description}`); @@ -828,7 +830,9 @@ export function formatThreadMarkersResult( .slice(0, 5); for (const stats of topRateTypes) { - if (!stats.rateStats) continue; + if (!stats.rateStats) { + continue; + } const { markersPerSecond, minGap, avgGap, maxGap } = stats.rateStats; lines.push( ` ${stats.markerName}: ${markersPerSecond.toFixed(1)} markers/sec (interval: min=${formatDuration(minGap)}, avg=${formatDuration(avgGap)}, max=${formatDuration(maxGap)})` diff --git a/profile-query-cli/src/index.ts b/profiler-cli/src/index.ts similarity index 96% rename from profile-query-cli/src/index.ts rename to profiler-cli/src/index.ts index 572b1fa476..431a9855cf 100644 --- a/profile-query-cli/src/index.ts +++ b/profiler-cli/src/index.ts @@ -15,11 +15,11 @@ * profiler-cli session use Switch the current session * * Build: - * yarn build-profile-query-cli + * yarn build-profiler-cli * * Run: * profiler-cli (if profiler-cli is in PATH) - * ./profile-query-cli/dist/profiler-cli.js (direct invocation) + * ./profiler-cli/dist/profiler-cli.js (direct invocation) */ import * as path from 'path'; @@ -42,7 +42,8 @@ import { registerSessionCommand } from './commands/session'; // Read session directory from environment (only place this is read) const SESSION_DIR = - process.env.PROFILER_CLI_SESSION_DIR || path.join(os.homedir(), '.profiler-cli'); + process.env.PROFILER_CLI_SESSION_DIR || + path.join(os.homedir(), '.profiler-cli'); async function main(): Promise { const rawArgs = process.argv.slice(2); diff --git a/profile-query-cli/src/output.ts b/profiler-cli/src/output.ts similarity index 100% rename from profile-query-cli/src/output.ts rename to profiler-cli/src/output.ts diff --git a/profile-query-cli/src/protocol.ts b/profiler-cli/src/protocol.ts similarity index 100% rename from profile-query-cli/src/protocol.ts rename to profiler-cli/src/protocol.ts diff --git a/profile-query-cli/src/session.ts b/profiler-cli/src/session.ts similarity index 100% rename from profile-query-cli/src/session.ts rename to profiler-cli/src/session.ts diff --git a/profile-query-cli/src/test/integration/basic.test.ts b/profiler-cli/src/test/integration/basic.test.ts similarity index 100% rename from profile-query-cli/src/test/integration/basic.test.ts rename to profiler-cli/src/test/integration/basic.test.ts diff --git a/profile-query-cli/src/test/integration/daemon-startup.test.ts b/profiler-cli/src/test/integration/daemon-startup.test.ts similarity index 100% rename from profile-query-cli/src/test/integration/daemon-startup.test.ts rename to profiler-cli/src/test/integration/daemon-startup.test.ts diff --git a/profile-query-cli/src/test/integration/sessions.test.ts b/profiler-cli/src/test/integration/sessions.test.ts similarity index 100% rename from profile-query-cli/src/test/integration/sessions.test.ts rename to profiler-cli/src/test/integration/sessions.test.ts diff --git a/profile-query-cli/src/test/integration/setup.ts b/profiler-cli/src/test/integration/setup.ts similarity index 100% rename from profile-query-cli/src/test/integration/setup.ts rename to profiler-cli/src/test/integration/setup.ts diff --git a/profile-query-cli/src/test/integration/utils.ts b/profiler-cli/src/test/integration/utils.ts similarity index 96% rename from profile-query-cli/src/test/integration/utils.ts rename to profiler-cli/src/test/integration/utils.ts index c8a82255dd..0ce375bd8c 100644 --- a/profile-query-cli/src/test/integration/utils.ts +++ b/profiler-cli/src/test/integration/utils.ts @@ -11,7 +11,7 @@ import { mkdtemp, readdir, readFile, rm } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; -const CLI_BIN = './profile-query-cli/dist/profiler-cli.js'; +const CLI_BIN = './profiler-cli/dist/profiler-cli.js'; /** * Simple command execution result. @@ -61,7 +61,9 @@ function exec( }); proc.on('close', (code) => { - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } if (timedOut) { reject(new Error(`Command timed out after ${options.timeout}ms`)); @@ -75,7 +77,9 @@ function exec( }); proc.on('error', (err) => { - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } reject(err); }); }); diff --git a/profile-query-cli/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap b/profiler-cli/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap similarity index 100% rename from profile-query-cli/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap rename to profiler-cli/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap diff --git a/profile-query-cli/src/test/unit/call-tree-formatting.test.ts b/profiler-cli/src/test/unit/call-tree-formatting.test.ts similarity index 100% rename from profile-query-cli/src/test/unit/call-tree-formatting.test.ts rename to profiler-cli/src/test/unit/call-tree-formatting.test.ts diff --git a/profile-query-cli/src/test/unit/client.test.ts b/profiler-cli/src/test/unit/client.test.ts similarity index 91% rename from profile-query-cli/src/test/unit/client.test.ts rename to profiler-cli/src/test/unit/client.test.ts index 91a7cb4004..215a9a5a05 100644 --- a/profile-query-cli/src/test/unit/client.test.ts +++ b/profiler-cli/src/test/unit/client.test.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Unit tests for profile-query-cli client. + * Unit tests for profiler-cli client. * * NOTE: This file intentionally contains no tests. * @@ -20,7 +20,7 @@ * client.ts, extract them to a separate module and test that module instead. */ -describe('profile-query-cli client', function () { +describe('profiler-cli client', function () { it('has no unit tests (see comment above)', function () { // This test exists only to prevent Jest from complaining about an empty suite expect(true).toBe(true); diff --git a/profile-query-cli/src/test/unit/daemon.test.ts b/profiler-cli/src/test/unit/daemon.test.ts similarity index 91% rename from profile-query-cli/src/test/unit/daemon.test.ts rename to profiler-cli/src/test/unit/daemon.test.ts index 7a4f1f4783..fa91e5e5e2 100644 --- a/profile-query-cli/src/test/unit/daemon.test.ts +++ b/profiler-cli/src/test/unit/daemon.test.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Unit tests for profile-query-cli daemon. + * Unit tests for profiler-cli daemon. * * NOTE: This file intentionally contains no tests. * @@ -20,7 +20,7 @@ * daemon.ts, extract them to a separate module and test that module instead. */ -describe('profile-query-cli daemon', function () { +describe('profiler-cli daemon', function () { it('has no unit tests (see comment above)', function () { // This test exists only to prevent Jest from complaining about an empty suite expect(true).toBe(true); diff --git a/profile-query-cli/src/test/unit/session.test.ts b/profiler-cli/src/test/unit/session.test.ts similarity index 96% rename from profile-query-cli/src/test/unit/session.test.ts rename to profiler-cli/src/test/unit/session.test.ts index 8c92901e2d..984b914ecc 100644 --- a/profile-query-cli/src/test/unit/session.test.ts +++ b/profiler-cli/src/test/unit/session.test.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Unit tests for profile-query CLI session management. + * Unit tests for profiler-cli session management. * * These tests cover only the session.ts utility functions. * Integration tests that spawn daemons and test IPC are in bash scripts: @@ -37,7 +37,7 @@ import type { SessionMetadata } from '../../protocol'; const TEST_BUILD_HASH = 'test-build-hash'; -describe('profile-query-cli session management', function () { +describe('profiler-cli session management', function () { let testSessionDir: string; const platformDescriptor = Object.getOwnPropertyDescriptor( process, @@ -46,7 +46,9 @@ describe('profile-query-cli session management', function () { beforeEach(function () { // Create a unique temp directory for each test - testSessionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'profiler-cli-test-')); + testSessionDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'profiler-cli-test-') + ); }); afterEach(function () { @@ -116,9 +118,18 @@ describe('profile-query-cli session management', function () { configurable: true, }); - const firstSocketPath = getSocketPath('C:\\profiler-cli\\alpha', 'test123'); - const secondSocketPath = getSocketPath('C:\\profiler-cli\\beta', 'test123'); - const thirdSocketPath = getSocketPath('C:\\PROFILER-CLI\\ALPHA', 'test123'); + const firstSocketPath = getSocketPath( + 'C:\\profiler-cli\\alpha', + 'test123' + ); + const secondSocketPath = getSocketPath( + 'C:\\profiler-cli\\beta', + 'test123' + ); + const thirdSocketPath = getSocketPath( + 'C:\\PROFILER-CLI\\ALPHA', + 'test123' + ); expect(firstSocketPath).toMatch( /^\\\\\.\\pipe\\profiler-cli-[0-9a-f]{12}-test123$/ diff --git a/profile-query-cli/src/utils/parse.ts b/profiler-cli/src/utils/parse.ts similarity index 100% rename from profile-query-cli/src/utils/parse.ts rename to profiler-cli/src/utils/parse.ts diff --git a/scripts/build-profile-query-cli.mjs b/scripts/build-profiler-cli.mjs similarity index 72% rename from scripts/build-profile-query-cli.mjs rename to scripts/build-profiler-cli.mjs index 2e8c2fa2cf..611db96737 100644 --- a/scripts/build-profile-query-cli.mjs +++ b/scripts/build-profiler-cli.mjs @@ -7,11 +7,11 @@ import { nodeBaseConfig } from './lib/esbuild-configs.mjs'; const BUILD_HASH = Date.now().toString(36); -const profileQueryCliConfig = { +const profilerCliConfig = { ...nodeBaseConfig, - entryPoints: ['profile-query-cli/src/index.ts'], + entryPoints: ['profiler-cli/src/index.ts'], loader: { ...nodeBaseConfig.loader, '.txt': 'text' }, - outfile: 'profile-query-cli/dist/profiler-cli.js', + outfile: 'profiler-cli/dist/profiler-cli.js', minify: true, banner: { js: '#!/usr/bin/env node\n\n// Polyfill browser globals for Node.js\nglobalThis.self = globalThis;', @@ -23,9 +23,9 @@ const profileQueryCliConfig = { }; async function build() { - await esbuild.build(profileQueryCliConfig); - chmodSync('profile-query-cli/dist/profiler-cli.js', 0o755); - console.log('✅ Profile-query-cli build completed'); + await esbuild.build(profilerCliConfig); + chmodSync('profiler-cli/dist/profiler-cli.js', 0o755); + console.log('✅ profiler-cli build completed'); } build().catch(console.error); diff --git a/src/profile-query/README.md b/src/profile-query/README.md index 1923af6af6..3e6af4bec3 100644 --- a/src/profile-query/README.md +++ b/src/profile-query/README.md @@ -4,7 +4,7 @@ A library for programmatically querying the contents of a Firefox Profiler profi ## Usage -**Note:** Most users should use the [profile-query-cli](../profile-query-cli/README.md) (`profiler-cli` command) instead of using this library directly. +**Note:** Most users should use the [profiler-cli](../profiler-cli/README.md) (`profiler-cli` command) instead of using this library directly. ### Building @@ -96,4 +96,4 @@ The library is built on top of the Firefox Profiler's Redux store and selectors: - **Function handles**: Canonical handles like `f-123` refer to shared `profile.shared.funcTable` indices and are stable across sessions for the same processed profile data - **Formatters**: Format query results into structured result objects -All query results are returned as typed result objects containing structured data. The CLI layer in `profile-query-cli` is responsible for formatting these into human-readable text. +All query results are returned as typed result objects containing structured data. The CLI layer in `profiler-cli` is responsible for formatting these into human-readable text. diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 36e8431e29..0d3c933fe7 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -4,7 +4,7 @@ /** * Shared types for profile querying. - * These types are used by both profile-query (the library) and profile-query-cli. + * These types are used by both profile-query (the library) and profiler-cli. */ // ===== Utility types ===== diff --git a/tsconfig.json b/tsconfig.json index 9f651ae75c..f6c0bf5a04 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -41,7 +41,7 @@ "include": [ "src/**/*.ts", "src/**/*.tsx", - "profile-query-cli/**/*.ts", + "profiler-cli/**/*.ts", "__mocks__/**/*.ts" ], "exclude": ["node_modules", "dist"] From e479949175bc3b396d5ed4c6a16157e2a0a2cec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Tue, 14 Apr 2026 18:52:50 +0200 Subject: [PATCH 42/63] Move implementation related things out of README.md of the cli --- profiler-cli/CONTRIBUTING.md | 267 +++++++++++++++++++++++++++++ profiler-cli/README.md | 316 ++++------------------------------- 2 files changed, 300 insertions(+), 283 deletions(-) create mode 100644 profiler-cli/CONTRIBUTING.md diff --git a/profiler-cli/CONTRIBUTING.md b/profiler-cli/CONTRIBUTING.md new file mode 100644 index 0000000000..3664cf4972 --- /dev/null +++ b/profiler-cli/CONTRIBUTING.md @@ -0,0 +1,267 @@ +# Contributing to Profiler CLI + +## Architecture + +**Two-process model:** + +- **Daemon process**: Long-running background process that loads a profile via `ProfileQuerier` and keeps it in memory +- **Client process**: Short-lived process that sends commands to the daemon and prints results + +**IPC:** Unix domain sockets (named pipes on Windows) with line-delimited JSON messages + +**Session storage:** `~/.profiler-cli/` (or `$PROFILER_CLI_SESSION_DIR` for development) + +`ProfileQuerier` lives in `src/profile-query/` in the main profiler repo and is shared with the web app. The CLI daemon is just an IPC wrapper around it — query logic belongs in `src/profile-query/`, not in `daemon.ts`. + +## Project Structure + +``` +profiler-cli/ +├── src/ +│ ├── index.ts # CLI entry point, argument parsing, command routing +│ ├── client.ts # Client logic: spawn daemon, send commands via socket +│ ├── daemon.ts # Daemon logic: load profile, listen on socket, handle commands +│ ├── session.ts # Session file management, socket paths, validation +│ ├── protocol.ts # TypeScript types for IPC messages +│ ├── formatters.ts # Plain-text formatters for structured command results +│ ├── constants.ts # Build-time constants (BUILD_HASH, etc.) +│ └── test/ +│ ├── unit/ # CLI unit tests +│ └── integration/ # CLI integration tests +├── package.json # npm distribution metadata (dependencies defined in root) +└── dist/ # Bundled executable output +``` + +## Build & Distribution + +This package uses a **bundled distribution approach**: + +- **Source code**: Lives in `profiler-cli/src/` within the firefox-devtools/profiler monorepo +- **Dependencies**: Defined in the root `package.json` (react, redux, protobufjs, etc.) +- **Build process**: The CLI build writes a single ~640KB executable to `profiler-cli/dist/profiler-cli.js` (~187KB gzipped) with zero runtime dependencies +- **Published artifact**: `profiler-cli/dist/profiler-cli.js` is published to npm as `@firefox-profiler/profiler-cli` +- **Package.json**: Contains only npm metadata — it does NOT list dependencies since they're pre-bundled + +This means: + +- Users who install via npm get a self-contained binary that just works +- Developers working on the CLI use the root package.json dependencies +- The `package.json` in this directory is for npm publishing only, not for development + +To publish: + +```bash +# From repository root +yarn build-profiler-cli +cd profiler-cli +npm publish +``` + +## Development Workflow + +**Environment variable isolation:** + +```bash +export PROFILER_CLI_SESSION_DIR="./.profiler-cli-dev" # Use local directory instead of ~/.profiler-cli +profiler-cli load profile.json # or: ./dist/profiler-cli.js load profile.json +``` + +All test scripts automatically set `PROFILER_CLI_SESSION_DIR="./.profiler-cli-dev"` to avoid polluting global state. + +**Build:** + +```bash +yarn build-profiler-cli # Creates ./dist/profiler-cli.js +``` + +**Unit tests:** + +```bash +yarn test profile-query +``` + +**CLI integration tests:** + +```bash +yarn test-cli +``` + +## Implementation Details + +**Daemon startup (client.ts):** + +Two-phase startup: + +1. Spawn detached Node.js process with `--daemon` flag +2. **Phase 1** — Poll every 50ms (max 500ms) until the session validates (metadata written, process running, socket exists) +3. **Phase 2** — Poll every 100ms (max 60s) via status messages until the profile finishes loading; fail immediately if a load error is returned +4. Return session ID when profile is ready + +**IPC protocol (protocol.ts):** + +```typescript +// Client → Daemon +type ClientMessage = + | { type: 'command'; command: ClientCommand } + | { type: 'shutdown' } + | { type: 'status' }; + +type ClientCommand = + | { command: 'profile'; subcommand: 'info' | 'threads'; all?: boolean; search?: string } + | { command: 'thread'; subcommand: 'info' | 'select' | 'samples' | 'samples-top-down' | 'samples-bottom-up' | 'markers' | 'functions'; thread?: string; ... } + | { command: 'marker'; subcommand: 'info' | 'select' | 'stack'; marker?: string } + | { command: 'sample'; subcommand: 'info' | 'select'; sample?: string } + | { command: 'function'; subcommand: 'info' | 'select' | 'expand'; function?: string } + | { command: 'zoom'; subcommand: 'push' | 'pop' | 'clear'; range?: string } + | { command: 'filter'; subcommand: 'push' | 'pop' | 'list' | 'clear'; thread?: string; spec?: SampleFilterSpec; count?: number } + | { command: 'status' }; + +// Daemon → Client +type ServerResponse = + | { type: 'success'; result: string | CommandResult } + | { type: 'error'; error: string } + | { type: 'loading' } + | { type: 'ready' }; +``` + +**Session validation (session.ts):** + +- Check PID is running (`process.kill(pid, 0)`) +- Check socket file exists (Unix only — named pipes on Windows are not filesystem files) +- Auto-cleanup stale sessions + +**Symlinks:** + +- `current` symlink uses relative path (`sessionId.sock`) +- Resolved to absolute in `getCurrentSocketPath()` when needed + +**Session metadata example:** + +```json +{ + "id": "abc123xyz", + "socketPath": "/Users/user/.profiler-cli/abc123xyz.sock", + "logPath": "/Users/user/.profiler-cli/abc123xyz.log", + "pid": 12345, + "profilePath": "/path/to/profile.json", + "createdAt": "2025-10-31T10:00:00.000Z", + "buildHash": "abc123" +} +``` + +On Windows, `socketPath` is a named pipe: `\\.\pipe\profiler-cli-`. + +## Build Configuration + +- esbuild bundles the CLI for Node.js +- A build banner adds the `#!/usr/bin/env node` shebang +- The banner also sets `globalThis.self = globalThis` for browser-oriented shared code +- `__BUILD_HASH__` is injected at build time +- `gecko-profiler-demangle` is left external to keep the CLI lean +- Postbuild: `chmod +x dist/profiler-cli.js` + +## Adding New Commands + +To add a new command, modify **5 files** (client.ts doesn't need changes as it generically forwards commands). The example below adds a hypothetical `profiler-cli allocation info` command. + +### Step 1: Define types in `protocol.ts` + +Add to the `ClientCommand` union, define a result type, and add it to `CommandResult`: + +```typescript +// In ClientCommand: +| { command: 'allocation'; subcommand: 'info'; thread?: string } + +// New result type: +export type AllocationInfoResult = { + type: 'allocation-info'; + totalBytes: number; + // ... other fields +}; + +// In CommandResult: +| WithContext +``` + +### Step 2: Parse CLI arguments in `index.ts` + +Add a case to the command switch, and add a corresponding case to the `formatOutput` switch: + +```typescript +// In the main command switch: +case 'allocation': { + const subcommand = argv._[1] ?? 'info'; + if (subcommand === 'info') { + const result = await sendCommand( + SESSION_DIR, + { command: 'allocation', subcommand, thread: argv.thread }, + argv.session + ); + console.log(formatOutput(result, argv.json || false)); + } else { + console.error(`Error: Unknown command ${command} ${subcommand}`); + process.exit(1); + } + break; +} + +// In the formatOutput switch: +case 'allocation-info': + return formatAllocationInfoResult(result); +``` + +client.ts doesn't need changes — it generically forwards all commands via `sendCommand()`. + +### Step 3: Handle the command in `daemon.ts` + +Add a case to `processCommand()`: + +```typescript +case 'allocation': + switch (command.subcommand) { + case 'info': + return this.querier!.allocationInfo(command.thread); + default: + throw assertExhaustiveCheck(command); + } +``` + +### Step 4: Implement the ProfileQuerier method in `src/profile-query/index.ts` + +Return a structured result type wrapped in `WithContext`, not a plain string: + +```typescript +async allocationInfo(threadHandle?: string): Promise> { + // ... + return { type: 'allocation-info', context: this._getContext(), totalBytes: ... }; +} +``` + +### Step 5: Add a formatter in `formatters.ts` + +```typescript +export function formatAllocationInfoResult( + result: WithContext +): string { + const lines: string[] = [formatContextHeader(result.context)]; + lines.push(`Total allocated: ${result.totalBytes} bytes`); + return lines.join('\n'); +} +``` + +Then import it at the top of `index.ts`. + +### Step 6: Update documentation + +- Add the command to the help text in `index.ts` (the `printUsage()` function) +- Add the command to the "Commands" section of `README.md` +- Remove it from the "Known Gaps" section below if it was previously stubbed out + +## Known Gaps + +These commands are parsed and routed but throw "unimplemented" in the daemon: + +- `profile threads` +- `marker select` +- `sample info`, `sample select` +- `function select` diff --git a/profiler-cli/README.md b/profiler-cli/README.md index 678050ea90..376e9abab4 100644 --- a/profiler-cli/README.md +++ b/profiler-cli/README.md @@ -2,29 +2,32 @@ A command-line interface for querying Firefox Profiler profiles with persistent daemon sessions. -## Architecture +## Installation -**Two-process model:** +```bash +npm install -g @firefox-profiler/profiler-cli +``` -- **Daemon process**: Long-running background process that loads a profile via `ProfileQuerier` and keeps it in memory -- **Client process**: Short-lived process that sends commands to the daemon and prints results +Requires Node.js >= 24. -**IPC:** Unix domain sockets (named pipes on Windows) with line-delimited JSON messages +## Quick Start -**Session storage:** `~/.profiler-cli/` (or `$PROFILER_CLI_SESSION_DIR` for development) +```bash +profiler-cli load profile.json # Load a profile (file or https:// URL) +profiler-cli profile info # Show profile summary +profiler-cli thread info # List threads +profiler-cli thread select t-0 # Select a thread +profiler-cli thread samples # Show hot functions +profiler-cli stop # Stop the daemon +``` -`ProfileQuerier` lives in `src/profile-query/` in the main profiler repo and is shared with the web app. The CLI daemon is just an IPC wrapper around it — query logic belongs in `src/profile-query/`, not in `daemon.ts`. +Run `profiler-cli guide` for a detailed usage guide with patterns and tips. +Run `profiler-cli --help` for the full options reference. ## Commands -**Note:** On machines with `profiler-cli` in PATH, you can use `profiler-cli` instead of `./dist/profiler-cli.js` for shorter commands. - ```bash -# Build the CLI -yarn build-profiler-cli - -# Basic usage -profiler-cli load # Start daemon and load profile (PATH can be a file or http/https URL) +profiler-cli load # Start daemon and load profile (file or http/https URL) profiler-cli profile info # Print profile summary [--all] [--search ] profiler-cli thread info # Print detailed thread information profiler-cli thread select # Select a thread (e.g., t-0, t-1) @@ -50,20 +53,24 @@ profiler-cli stop # Stop a specific session profiler-cli stop --all # Stop all sessions profiler-cli session list # List all running daemon sessions (* marks current) profiler-cli session use # Switch the current session +``` + +### Multiple sessions -# Multiple sessions +```bash profiler-cli load --session profiler-cli profile info --session +``` + +### Thread selection -# Thread selection +```bash profiler-cli thread select t-93 # Select thread t-93 profiler-cli thread samples # View samples for selected thread profiler-cli thread info --thread t-0 # View info for specific thread without selecting ``` -Run `profiler-cli guide` for a detailed usage guide with patterns and tips. Run `profiler-cli --help` for the full options reference. - -### Common options +## Options | Flag | Description | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -77,7 +84,7 @@ Run `profiler-cli guide` for a detailed usage guide with patterns and tips. Run | `--all` | Show all threads in `profile info` (overrides default top-5 limit) | | `--session ` | Use a specific session instead of the current one | -### Sample filter flags +## Sample Filter Flags These work ephemerally on `thread samples` / `thread functions`, and as persistent filters via `filter push`. @@ -94,271 +101,14 @@ These work ephemerally on `thread samples` / `thread functions`, and as persiste For `filter push`, exactly one flag per push. For ephemeral use, multiple flags may be combined and applied left-to-right; the same flag may also be repeated (e.g. `--merge f-1 --merge f-2`). -## Project Structure - -``` -profiler-cli/ -├── src/ -│ ├── index.ts # CLI entry point, argument parsing, command routing -│ ├── client.ts # Client logic: spawn daemon, send commands via socket -│ ├── daemon.ts # Daemon logic: load profile, listen on socket, handle commands -│ ├── session.ts # Session file management, socket paths, validation -│ ├── protocol.ts # TypeScript types for IPC messages -│ ├── formatters.ts # Plain-text formatters for structured command results -│ ├── constants.ts # Build-time constants (BUILD_HASH, etc.) -│ └── test/ -│ ├── unit/ # CLI unit tests -│ └── integration/ # CLI integration tests -├── package.json # npm distribution metadata (dependencies defined in root) -└── dist/ # Bundled executable output -``` - -## Build & Distribution - -This package uses a **bundled distribution approach**: - -- **Source code**: Lives in `profiler-cli/src/` within the firefox-devtools/profiler monorepo -- **Dependencies**: Defined in the root `package.json` (react, redux, protobufjs, etc.) -- **Build process**: The CLI build writes a single ~640KB executable to `profiler-cli/dist/profiler-cli.js` (~187KB gzipped) with zero runtime dependencies -- **Published artifact**: `profiler-cli/dist/profiler-cli.js` is published to npm as `@firefox-profiler/profiler-cli` -- **Package.json**: Contains only npm metadata - it does NOT list dependencies since they're pre-bundled - -This means: - -- Users who install via npm get a self-contained binary that just works -- Developers working on the CLI use the root package.json dependencies -- The `package.json` in this directory is for npm publishing only, not for development - -To publish: - -```bash -# From repository root -yarn build-profiler-cli -cd profiler-cli -npm publish -``` - -## Session Management - -**Session directory:** `~/.profiler-cli/` or `$PROFILER_CLI_SESSION_DIR` - -**Files per session:** - -``` -~/.profiler-cli/ -├── current # Symlink to current session socket -├── .sock # Unix domain socket for IPC (Unix only) -├── .json # Session metadata (PID, profile path, timestamps) -└── .log # Daemon logs (kept for debugging) -``` - -On Windows, IPC uses a named pipe (`\\.\pipe\profiler-cli-`) instead of a `.sock` file. - -**Session metadata example:** - -```json -{ - "id": "abc123xyz", - "socketPath": "/Users/user/.profiler-cli/abc123xyz.sock", // or \\.\pipe\profiler-cli-abc123xyz on Windows - "logPath": "/Users/user/.profiler-cli/abc123xyz.log", - "pid": 12345, - "profilePath": "/path/to/profile.json", - "createdAt": "2025-10-31T10:00:00.000Z", - "buildHash": "abc123" -} -``` - -## Development Workflow +## Session Storage -**Environment variable isolation:** - -```bash -export PROFILER_CLI_SESSION_DIR="./.profiler-cli-dev" # Use local directory instead of ~/.profiler-cli -profiler-cli load profile.json # or: ./dist/profiler-cli.js load profile.json -``` - -All test scripts automatically set `PROFILER_CLI_SESSION_DIR="./.profiler-cli-dev"` to avoid polluting global state. - -**Build:** - -```bash -yarn build-profiler-cli # Creates ./dist/profiler-cli.js -``` - -**Unit tests:** - -```bash -yarn test profile-query -``` +Sessions are stored in `~/.profiler-cli/` (or `$PROFILER_CLI_SESSION_DIR` to override). -**CLI integration tests:** - -```bash -yarn test-cli -``` - -## Implementation Details - -**Daemon startup (client.ts):** - -Two-phase startup: - -1. Spawn detached Node.js process with `--daemon` flag -2. **Phase 1** — Poll every 50ms (max 500ms) until the session validates (metadata written, process running, socket exists) -3. **Phase 2** — Poll every 100ms (max 60s) via status messages until the profile finishes loading; fail immediately if a load error is returned -4. Return session ID when profile is ready - -**IPC protocol (protocol.ts):** - -```typescript -// Client → Daemon -type ClientMessage = - | { type: 'command'; command: ClientCommand } - | { type: 'shutdown' } - | { type: 'status' }; - -type ClientCommand = - | { command: 'profile'; subcommand: 'info' | 'threads'; all?: boolean; search?: string } - | { command: 'thread'; subcommand: 'info' | 'select' | 'samples' | 'samples-top-down' | 'samples-bottom-up' | 'markers' | 'functions'; thread?: string; ... } - | { command: 'marker'; subcommand: 'info' | 'select' | 'stack'; marker?: string } - | { command: 'sample'; subcommand: 'info' | 'select'; sample?: string } - | { command: 'function'; subcommand: 'info' | 'select' | 'expand'; function?: string } - | { command: 'zoom'; subcommand: 'push' | 'pop' | 'clear'; range?: string } - | { command: 'filter'; subcommand: 'push' | 'pop' | 'list' | 'clear'; thread?: string; spec?: SampleFilterSpec; count?: number } - | { command: 'status' }; - -// Daemon → Client -type ServerResponse = - | { type: 'success'; result: string | CommandResult } - | { type: 'error'; error: string } - | { type: 'loading' } - | { type: 'ready' }; -``` - -**Session validation (session.ts):** - -- Check PID is running (`process.kill(pid, 0)`) -- Check socket file exists (Unix only — named pipes on Windows are not filesystem files) -- Auto-cleanup stale sessions - -**Symlinks:** - -- `current` symlink uses relative path (`sessionId.sock`) -- Resolved to absolute in `getCurrentSocketPath()` when needed - -## Known Gaps - -These commands are parsed and routed but throw "unimplemented" in the daemon: - -- `profile threads` -- `marker select` -- `sample info`, `sample select` -- `function select` - -## Build Configuration - -**Current build behavior:** - -- esbuild bundles the CLI for Node.js -- A build banner adds the `#!/usr/bin/env node` shebang -- The banner also sets `globalThis.self = globalThis` for browser-oriented shared code -- `__BUILD_HASH__` is injected at build time -- `gecko-profiler-demangle` is left external to keep the CLI lean -- Postbuild: `chmod +x dist/profiler-cli.js` - -## Adding New Commands - -To add a new command, you need to modify **5 files** (client.ts doesn't need changes as it generically forwards commands). The example below adds a hypothetical `profiler-cli allocation info` command. - -### Step 1: Define types in `protocol.ts` - -Add to the `ClientCommand` union, define a result type, and add it to `CommandResult`: - -```typescript -// In ClientCommand: -| { command: 'allocation'; subcommand: 'info'; thread?: string } - -// New result type: -export type AllocationInfoResult = { - type: 'allocation-info'; - totalBytes: number; - // ... other fields -}; - -// In CommandResult: -| WithContext -``` - -### Step 2: Parse CLI arguments in `index.ts` - -Add a case to the command switch, and add a corresponding case to the `formatOutput` switch: - -```typescript -// In the main command switch: -case 'allocation': { - const subcommand = argv._[1] ?? 'info'; - if (subcommand === 'info') { - const result = await sendCommand( - SESSION_DIR, - { command: 'allocation', subcommand, thread: argv.thread }, - argv.session - ); - console.log(formatOutput(result, argv.json || false)); - } else { - console.error(`Error: Unknown command ${command} ${subcommand}`); - process.exit(1); - } - break; -} - -// In the formatOutput switch: -case 'allocation-info': - return formatAllocationInfoResult(result); -``` - -**Note:** client.ts doesn't need changes — it generically forwards all commands via `sendCommand()`. - -### Step 3: Handle the command in `daemon.ts` - -Add a case to `processCommand()`: - -```typescript -case 'allocation': - switch (command.subcommand) { - case 'info': - return this.querier!.allocationInfo(command.thread); - default: - throw assertExhaustiveCheck(command); - } -``` - -### Step 4: Implement the ProfileQuerier method in `src/profile-query/index.ts` - -Return a structured result type wrapped in `WithContext`, not a plain string: - -```typescript -async allocationInfo(threadHandle?: string): Promise> { - // ... - return { type: 'allocation-info', context: this._getContext(), totalBytes: ... }; -} -``` - -### Step 5: Add a formatter in `formatters.ts` - -```typescript -export function formatAllocationInfoResult( - result: WithContext -): string { - const lines: string[] = [formatContextHeader(result.context)]; - lines.push(`Total allocated: ${result.totalBytes} bytes`); - return lines.join('\n'); -} -``` +## Contributing -Then import it at the top of `index.ts`. +See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture, build instructions, and how to add new commands. -### Step 6: Update documentation +## License -- Add the command to the help text in `index.ts` (the `printUsage()` function) -- Add the command to the "Commands" section of this README -- Remove it from the "Known Gaps" section if it was previously stubbed out +[MPL-2.0](https://www.mozilla.org/en-US/MPL/2.0/) From cc7c199de8380ebf2131f646b68ec3b3e93f7b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Tue, 14 Apr 2026 19:00:41 +0200 Subject: [PATCH 43/63] Update package.json and license of cli --- profiler-cli/LICENSE | 373 ++++++++++++++++++++++++++++++++++++++ profiler-cli/package.json | 8 +- 2 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 profiler-cli/LICENSE diff --git a/profiler-cli/LICENSE b/profiler-cli/LICENSE new file mode 100644 index 0000000000..a612ad9813 --- /dev/null +++ b/profiler-cli/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + 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/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/profiler-cli/package.json b/profiler-cli/package.json index 433e141674..3f80d99aef 100644 --- a/profiler-cli/package.json +++ b/profiler-cli/package.json @@ -1,8 +1,10 @@ { - "_comment": "This is a distribution package for a pre-bundled CLI artifact. Build dependencies are defined in the root package.json. Run 'yarn build-profiler-cli' from the repository root to build dist/profiler-cli.js.", "name": "@firefox-profiler/profiler-cli", "version": "0.1.0", "description": "Command-line interface for querying Firefox Profiler profiles with persistent daemon sessions", + "scripts": { + "prepublishOnly": "test -f dist/profiler-cli.js || (echo 'Run yarn build-profiler-cli from the repo root first' && exit 1)" + }, "main": "./dist/profiler-cli.js", "bin": { "profiler-cli": "./dist/profiler-cli.js" @@ -11,12 +13,12 @@ "dist/profiler-cli.js" ], "engines": { - "node": ">= 24 < 25" + "node": ">= 24" }, "devEngines": { "runtime": { "name": "node", - "version": ">= 24 < 25" + "version": ">= 24" } }, "keywords": [ From 314e555612b95a7380700fffb08d23846b47c94d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Tue, 14 Apr 2026 19:12:05 +0200 Subject: [PATCH 44/63] Update naming --- profiler-cli/guide.txt | 2 +- profiler-cli/src/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index 9912e3acee..bdb6bbc61e 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -1,6 +1,6 @@ profiler-cli: Usage Guide -profiler-cli (Profile Querier) queries Firefox performance profiles loaded into a persistent +profiler-cli (Profiler CLI) queries Firefox performance profiles loaded into a persistent daemon. Load a profile once, then query it interactively without reloading. diff --git a/profiler-cli/src/index.ts b/profiler-cli/src/index.ts index 431a9855cf..84498d3e8b 100644 --- a/profiler-cli/src/index.ts +++ b/profiler-cli/src/index.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * CLI entry point for profiler-cli (Profile Querier). + * CLI entry point for profiler-cli (Profiler CLI). * * Usage: * profiler-cli load [--session ] Start a new daemon and load a profile @@ -67,7 +67,7 @@ async function main(): Promise { const program = new Command(); program .name('profiler-cli') - .description('Profile Querier — query Firefox profiles from the terminal') + .description('Profiler CLI — query Firefox profiles from the terminal') .helpOption('-h, --help', 'Show help') .addHelpCommand('help [command]', 'Show help for a command') .addHelpText( From ef7d253d68e8ed1f74fb2bf49eaaf54e0062a58a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Tue, 14 Apr 2026 19:20:06 +0200 Subject: [PATCH 45/63] Trim the guide --- profiler-cli/guide.txt | 53 ++++++------------------------------------ 1 file changed, 7 insertions(+), 46 deletions(-) diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index bdb6bbc61e..7b7fe654fe 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -12,24 +12,10 @@ QUICK START profiler-cli thread samples Hot functions by self time profiler-cli thread samples-top-down Full top-down call tree - The rest of this guide covers filtering, zooming, markers, and advanced analysis. - - -SESSION MODEL - - profiler-cli uses a daemon/session model: - 1. Load a profile with "profiler-cli load" -- starts a background daemon - 2. Query the loaded profile with any other commands - 3. Stop with "profiler-cli stop" when done - - The daemon persists your selected thread, zoom ranges, and filter stacks across queries. + profiler-cli uses a daemon model: "profiler-cli load" starts a background daemon that + persists your selected thread, zoom ranges, and filter stacks. Stop it with "profiler-cli stop". - For scripting or concurrent work, prefer an explicit session ID so each workflow - gets isolated state: - profiler-cli load profile.json.gz --session run-a - profiler-cli profile info --session run-a - profiler-cli thread select t-0 --session run-a - profiler-cli thread samples --session run-a + The rest of this guide covers filtering, zooming, markers, and advanced analysis. CORE WORKFLOW @@ -83,17 +69,10 @@ CORE WORKFLOW Step 7: Filter to focus the analysis (see FILTERS section below) - Ephemeral -- applies only to that one command, sticky state unchanged: - profiler-cli thread samples --excludes-function f-184 Ignore one noisy function - profiler-cli thread samples --root-at f-500 Show only work inside f-500 - profiler-cli thread samples --during-marker --search Paint Only samples during Paint markers - - Sticky -- persists across all subsequent commands until popped: - profiler-cli filter push --merge f-142,f-143 Merge allocators out of stacks - profiler-cli filter push --includes-function f-500 Keep only samples containing f-500 - profiler-cli thread samples Uses sticky filters automatically - profiler-cli filter pop Remove last filter - profiler-cli filter clear Remove all filters + profiler-cli thread samples --excludes-function f-184 Ephemeral: one command only + profiler-cli filter push --includes-function f-500 Sticky: persists until popped + profiler-cli filter pop Remove last filter + profiler-cli filter clear Remove all filters SESSION STATE @@ -183,7 +162,6 @@ FILTERS Inclusion -- keep only samples whose stack matches: --includes-function f-N,... stack contains any of these funcs (OR) - --includes-any-function f-N,... alias for --includes-function --includes-prefix f-N,... stack starts with this root-first sequence --includes-suffix f-N leaf (innermost) frame is f-N --during-marker --search sample timestamp inside a matching marker @@ -191,7 +169,6 @@ FILTERS Exclusion -- drop matching samples: --excludes-function f-N,... stack contains any of these funcs - --excludes-any-function f-N,... alias for --excludes-function Stack transforms -- modify stack structure: --merge f-N,... remove funcs from stacks (A→f-N→B becomes A→B) @@ -223,13 +200,6 @@ JSON OUTPUT COMMON ANALYSIS PATTERNS - Find what's consuming CPU: - profiler-cli thread info Pick the main thread (usually GeckoMain) - profiler-cli thread select t-0 - profiler-cli thread samples Top hot functions by self time (active only) - profiler-cli thread samples-top-down See the full call tree - profiler-cli thread samples-top-down --search GC Focus tree on paths through "GC" - Investigate a jank/slow period: profiler-cli thread markers --min-duration 50 Find long-running markers (>50ms = jank) profiler-cli zoom push m-158 Zoom into that marker's time range @@ -301,22 +271,13 @@ UNDERSTANDING THE OUTPUT samples-bottom-up Hot leaf functions at top with their callers. Use to understand what calls a frequently-seen function. - Scoring strategies (controls node ranking when tree is truncated with --max-lines): - exponential-0.9 (default) Balances depth vs breadth - percentage-only Rank purely by CPU percentage (flatter output) - harmonic-1.0 Emphasizes deeper paths in the tree - - TIPS - Start with "profile info"; GeckoMain is usually the main thread -- use "--search GeckoMain" to find it quickly - - Self time is more actionable: focus on high self-time functions for optimization - Idle time alone is not a finding: only investigate idle during a period when the thread should be busy (e.g. inside a zoom on a jank marker), which may indicate lock contention or blocking on another thread - - --search narrows call PATHS: "samples-top-down --search GC" shows only call - paths that pass through a GC-related function, not just functions named "GC" - --search with comma is AND, not OR: "--search foo,bar" keeps only samples where BOTH "foo" and "bar" appear somewhere in the stack; to get OR behavior, run two separate searches; "|" is a literal character and will match nothing From 0ebdd33d0ee1d9b4155659420578e531112f76bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Tue, 14 Apr 2026 19:42:56 +0200 Subject: [PATCH 46/63] Add 'profiler-cli thread network' command --- profiler-cli/guide.txt | 14 +- profiler-cli/src/commands/thread.ts | 73 ++++ profiler-cli/src/daemon.ts | 5 + profiler-cli/src/formatters.ts | 129 +++++++ profiler-cli/src/output.ts | 3 + profiler-cli/src/protocol.ts | 16 +- .../src/test/unit/network-formatting.test.ts | 257 +++++++++++++ src/profile-query/formatters/marker-info.ts | 192 ++++++++++ src/profile-query/index.ts | 23 ++ src/profile-query/types.ts | 41 ++ .../marker-info-formatters.test.ts | 362 ++++++++++++++++++ 11 files changed, 1110 insertions(+), 5 deletions(-) create mode 100644 profiler-cli/src/test/unit/network-formatting.test.ts diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index 7b7fe654fe..7d52091189 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -61,6 +61,13 @@ CORE WORKFLOW profiler-cli thread markers --min-duration 10 Only markers >= 10ms profiler-cli thread markers --has-stack Only markers with stack traces + profiler-cli thread network First 20 requests with timing phases (default) + profiler-cli thread network --limit 0 All requests (no limit) + profiler-cli thread network --search "api.example" Filter by URL substring + profiler-cli thread network --min-duration 200 Only requests >= 200ms + profiler-cli thread network --max-duration 50 Only requests <= 50ms + profiler-cli thread network --limit 50 Show up to 50 requests + Step 6: Drill into specifics profiler-cli marker info m-1234 Full details for a marker (from handles in marker list) profiler-cli marker stack m-1234 Full stack trace at the time of a marker @@ -243,9 +250,10 @@ COMMON ANALYSIS PATTERNS profiler-cli thread markers --group-by type,name Analyze network activity: - profiler-cli thread markers --category Network - profiler-cli thread markers --category Network --min-duration 100 - profiler-cli thread markers --search "http" --category Network + profiler-cli thread network All requests with timing phases + profiler-cli thread network --min-duration 200 Slow requests only (>= 200ms) + profiler-cli thread network --search "api" Filter by URL substring + profiler-cli thread markers --category Network Cross-reference with marker view UNDERSTANDING THE OUTPUT diff --git a/profiler-cli/src/commands/thread.ts b/profiler-cli/src/commands/thread.ts index dcba519a82..bf51a6fc85 100644 --- a/profiler-cli/src/commands/thread.ts +++ b/profiler-cli/src/commands/thread.ts @@ -314,6 +314,79 @@ export function registerThreadCommand( console.log(formatOutput(result, opts.json ?? false)); }); + // thread network + addGlobalOptions( + thread + .command('network') + .description('Show network requests with timing phases') + .option('--thread ', 'Thread handle (e.g. t-0)') + .option('--search ', 'Filter by URL substring') + .option( + '--min-duration ', + 'Filter by minimum total request duration in milliseconds' + ) + .option( + '--max-duration ', + 'Filter by maximum total request duration in milliseconds' + ) + .option('--limit ', 'Max requests to show (default: 20, 0 = show all)') + ).action(async (opts) => { + const networkFilters: { + searchString?: string; + minDuration?: number; + maxDuration?: number; + limit?: number; + } = {}; + + if (opts.search !== undefined) { + networkFilters.searchString = opts.search; + } + if (opts.minDuration !== undefined) { + const v = parseFloat(opts.minDuration); + if (isNaN(v) || v < 0) { + console.error( + 'Error: --min-duration must be a positive number (in milliseconds)' + ); + process.exit(1); + } + networkFilters.minDuration = v; + } + if (opts.maxDuration !== undefined) { + const v = parseFloat(opts.maxDuration); + if (isNaN(v) || v < 0) { + console.error( + 'Error: --max-duration must be a positive number (in milliseconds)' + ); + process.exit(1); + } + networkFilters.maxDuration = v; + } + if (opts.limit !== undefined) { + const v = parseInt(opts.limit, 10); + if (isNaN(v) || v < 0) { + console.error( + 'Error: --limit must be a non-negative integer (0 = show all)' + ); + process.exit(1); + } + networkFilters.limit = v; + } else { + networkFilters.limit = 20; + } + + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'network', + thread: opts.thread, + networkFilters, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + // thread functions addSampleFilterOptions( addGlobalOptions( diff --git a/profiler-cli/src/daemon.ts b/profiler-cli/src/daemon.ts index 04e8a27625..1277844e4f 100644 --- a/profiler-cli/src/daemon.ts +++ b/profiler-cli/src/daemon.ts @@ -331,6 +331,11 @@ export class Daemon { command.includeIdle, command.sampleFilters ); + case 'network': + return this.querier!.threadNetwork( + command.thread, + command.networkFilters + ); default: throw assertExhaustiveCheck(command); } diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 9d767345be..0e0f07ebdf 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -24,6 +24,8 @@ import type { ThreadSamplesBottomUpResult, ThreadMarkersResult, ThreadFunctionsResult, + ThreadNetworkResult, + NetworkPhaseTimings, MarkerGroupData, CallTreeNode, FilterEntry, @@ -1014,3 +1016,130 @@ export function formatThreadFunctionsResult( return lines.join('\n'); } + +function formatNetworkPhases(phases: NetworkPhaseTimings): string { + const parts: string[] = []; + if (phases.dns !== undefined) { + parts.push(`DNS=${formatDuration(phases.dns)}`); + } + if (phases.tcp !== undefined) { + parts.push(`TCP=${formatDuration(phases.tcp)}`); + } + if (phases.tls !== undefined) { + parts.push(`TLS=${formatDuration(phases.tls)}`); + } + if (phases.ttfb !== undefined) { + parts.push(`TTFB=${formatDuration(phases.ttfb)}`); + } + if (phases.download !== undefined) { + parts.push(`DL=${formatDuration(phases.download)}`); + } + if (phases.mainThread !== undefined) { + parts.push(`wait=${formatDuration(phases.mainThread)}`); + } + return parts.join(' '); +} + +export function formatThreadNetworkResult( + result: WithContext +): string { + const lines: string[] = [formatContextHeader(result.context), '']; + + const filterSuffix = + result.filters !== undefined && + result.filteredRequestCount !== result.totalRequestCount + ? ` (filtered from ${result.totalRequestCount})` + : ''; + + const truncated = result.requests.length < result.filteredRequestCount; + const countStr = truncated + ? `${result.requests.length} of ${result.filteredRequestCount} requests` + : `${result.filteredRequestCount} requests`; + + lines.push( + `Network requests in thread ${result.threadHandle} (${result.friendlyThreadName}) — ${countStr}${filterSuffix}` + ); + lines.push(''); + + // Summary + const s = result.summary; + lines.push('Summary:'); + lines.push( + ` Cache: ${s.cacheHit} hit, ${s.cacheMiss} miss, ${s.cacheUnknown} unknown` + ); + + const pt = s.phaseTotals; + const hasPhaseTotals = + pt.dns !== undefined || + pt.tcp !== undefined || + pt.tls !== undefined || + pt.ttfb !== undefined || + pt.download !== undefined || + pt.mainThread !== undefined; + + if (hasPhaseTotals) { + lines.push(' Phase totals:'); + if (pt.dns !== undefined) { + lines.push(` DNS: ${formatDuration(pt.dns)}`); + } + if (pt.tcp !== undefined) { + lines.push(` TCP connect: ${formatDuration(pt.tcp)}`); + } + if (pt.tls !== undefined) { + lines.push(` TLS: ${formatDuration(pt.tls)}`); + } + if (pt.ttfb !== undefined) { + lines.push(` TTFB: ${formatDuration(pt.ttfb)}`); + } + if (pt.download !== undefined) { + lines.push(` Download: ${formatDuration(pt.download)}`); + } + if (pt.mainThread !== undefined) { + lines.push(` Main thread wait: ${formatDuration(pt.mainThread)}`); + } + } + + lines.push(''); + + if (result.requests.length === 0) { + lines.push('No network requests match the specified filters.'); + return lines.join('\n'); + } + + for (const req of result.requests) { + const url = req.url.length > 100 ? req.url.slice(0, 97) + '...' : req.url; + const status = + req.httpStatus !== undefined ? String(req.httpStatus) : '???'; + const version = req.httpVersion !== undefined ? ` ${req.httpVersion}` : ''; + const cache = + req.cacheStatus !== undefined ? ` cache=${req.cacheStatus}` : ''; + const size = + req.transferSizeKB !== undefined + ? ` size=${req.transferSizeKB.toFixed(1)}KB` + : ''; + + lines.push(` ${url}`); + lines.push( + ` ${status}${version}${cache}${size} duration=${formatDuration(req.duration)}` + ); + + const phaseStr = formatNetworkPhases(req.phases); + if (phaseStr) { + lines.push(` Phases: ${phaseStr}`); + } + + lines.push(''); + } + + if (truncated) { + lines.push( + `Use --limit 0 to show all requests, or --limit to set a different limit.` + ); + } else { + lines.push( + 'Use --search , --min-duration , --max-duration , or --limit to filter.' + ); + } + + return lines.join('\n'); +} diff --git a/profiler-cli/src/output.ts b/profiler-cli/src/output.ts index bcb8d931aa..2ecaf24709 100644 --- a/profiler-cli/src/output.ts +++ b/profiler-cli/src/output.ts @@ -23,6 +23,7 @@ import { formatThreadSamplesBottomUpResult, formatThreadMarkersResult, formatThreadFunctionsResult, + formatThreadNetworkResult, } from './formatters'; /** @@ -73,6 +74,8 @@ export function formatOutput( return formatThreadMarkersResult(result); case 'thread-functions': return formatThreadFunctionsResult(result); + case 'thread-network': + return formatThreadNetworkResult(result); default: throw assertExhaustiveCheck(result); } diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts index d3f693f9f6..37467e8e7e 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -27,6 +27,9 @@ export type { CallTreeNode, CallTreeScoringStrategy, ThreadMarkersResult, + ThreadNetworkResult, + NetworkRequestEntry, + NetworkPhaseTimings, ThreadFunctionsResult, DurationStats, RateStats, @@ -56,6 +59,7 @@ import type { ThreadSamplesTopDownResult, ThreadSamplesBottomUpResult, ThreadMarkersResult, + ThreadNetworkResult, ThreadFunctionsResult, FilterStackResult, } from '../../src/profile-query/types'; @@ -82,13 +86,20 @@ export type ClientCommand = | 'samples-top-down' | 'samples-bottom-up' | 'markers' - | 'functions'; + | 'functions' + | 'network'; thread?: string; includeIdle?: boolean; search?: string; markerFilters?: MarkerFilterOptions; functionFilters?: FunctionFilterOptions; callTreeOptions?: CallTreeCollectionOptions; + networkFilters?: { + searchString?: string; + minDuration?: number; + maxDuration?: number; + limit?: number; + }; /** Ephemeral sample filters applied only to this command invocation */ sampleFilters?: SampleFilterSpec[]; } @@ -141,7 +152,8 @@ export type CommandResult = | WithContext | WithContext | WithContext - | WithContext; + | WithContext + | WithContext; export interface SessionMetadata { id: string; diff --git a/profiler-cli/src/test/unit/network-formatting.test.ts b/profiler-cli/src/test/unit/network-formatting.test.ts new file mode 100644 index 0000000000..b8b69cae76 --- /dev/null +++ b/profiler-cli/src/test/unit/network-formatting.test.ts @@ -0,0 +1,257 @@ +/* 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/. */ + +import { formatThreadNetworkResult } from '../../formatters'; +import type { + ThreadNetworkResult, + NetworkRequestEntry, + NetworkPhaseTimings, + SessionContext, + WithContext, +} from 'firefox-profiler/profile-query/types'; + +function createContext(): SessionContext { + return { + selectedThreadHandle: 't-0', + selectedThreads: [{ threadIndex: 0, name: 'GeckoMain' }], + currentViewRange: null, + rootRange: { start: 0, end: 1000 }, + }; +} + +function makeRequest( + overrides: Partial = {} +): NetworkRequestEntry { + return { + url: 'https://example.com/resource', + startTime: 0, + duration: 100, + phases: {}, + ...overrides, + }; +} + +function makeResult( + overrides: Partial = {} +): WithContext { + return { + context: createContext(), + type: 'thread-network', + threadHandle: 't-0', + friendlyThreadName: 'GeckoMain', + totalRequestCount: 1, + filteredRequestCount: 1, + summary: { + cacheHit: 0, + cacheMiss: 0, + cacheUnknown: 1, + phaseTotals: {}, + }, + requests: [makeRequest()], + ...overrides, + }; +} + +describe('formatThreadNetworkResult', function () { + it('shows thread handle and request count', function () { + const result = makeResult({ + filteredRequestCount: 3, + totalRequestCount: 3, + }); + result.requests = [ + makeRequest({ url: 'https://a.com' }), + makeRequest({ url: 'https://b.com' }), + makeRequest({ url: 'https://c.com' }), + ]; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('t-0'); + expect(output).toContain('3 requests'); + }); + + it('shows "(filtered from N)" suffix when filter reduces count', function () { + const result = makeResult({ + totalRequestCount: 10, + filteredRequestCount: 3, + filters: { searchString: 'api' }, + }); + result.requests = [makeRequest()]; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('(filtered from 10)'); + }); + + it('shows "X of Y requests" in header when limit truncates results', function () { + const result = makeResult({ + filteredRequestCount: 50, + totalRequestCount: 50, + }); + result.requests = [makeRequest(), makeRequest()]; // only 2 shown of 50 + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('2 of 50 requests'); + }); + + it('shows --limit 0 hint in footer when results are truncated', function () { + const result = makeResult({ + filteredRequestCount: 50, + totalRequestCount: 50, + }); + result.requests = [makeRequest()]; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('--limit 0'); + }); + + it('shows normal filter hint in footer when results are not truncated', function () { + const result = makeResult({ + filteredRequestCount: 1, + totalRequestCount: 1, + }); + result.requests = [makeRequest()]; + + const output = formatThreadNetworkResult(result); + + expect(output).not.toContain('--limit 0'); + expect(output).toContain('--search'); + }); + + it('does not show filtered suffix when counts are equal', function () { + const result = makeResult({ + totalRequestCount: 2, + filteredRequestCount: 2, + filters: { minDuration: 50 }, + }); + result.requests = [makeRequest(), makeRequest()]; + + const output = formatThreadNetworkResult(result); + + expect(output).not.toContain('filtered from'); + }); + + it('shows cache summary counts', function () { + const result = makeResult({ + summary: { + cacheHit: 4, + cacheMiss: 2, + cacheUnknown: 1, + phaseTotals: {}, + }, + }); + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('4 hit'); + expect(output).toContain('2 miss'); + expect(output).toContain('1 unknown'); + }); + + it('shows phase totals section when any phase total is present', function () { + const phaseTotals: NetworkPhaseTimings = { ttfb: 50, download: 30 }; + const result = makeResult({ + summary: { cacheHit: 0, cacheMiss: 1, cacheUnknown: 0, phaseTotals }, + }); + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('Phase totals'); + expect(output).toContain('TTFB'); + expect(output).toContain('Download'); + }); + + it('omits phase totals section when no phases are present', function () { + const result = makeResult({ + summary: { cacheHit: 1, cacheMiss: 0, cacheUnknown: 0, phaseTotals: {} }, + }); + + const output = formatThreadNetworkResult(result); + + expect(output).not.toContain('Phase totals'); + }); + + it('shows each request URL', function () { + const result = makeResult({ + filteredRequestCount: 2, + totalRequestCount: 2, + }); + result.requests = [ + makeRequest({ url: 'https://api.example.com/data' }), + makeRequest({ url: 'https://static.example.com/img.png' }), + ]; + result.summary.cacheUnknown = 2; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('https://api.example.com/data'); + expect(output).toContain('https://static.example.com/img.png'); + }); + + it('truncates URLs longer than 100 characters', function () { + const longUrl = 'https://example.com/' + 'a'.repeat(90); + const result = makeResult(); + result.requests = [makeRequest({ url: longUrl })]; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('...'); + expect(output).not.toContain(longUrl); + }); + + it('shows per-request phases when present', function () { + const phases: NetworkPhaseTimings = { dns: 5, ttfb: 30 }; + const result = makeResult(); + result.requests = [makeRequest({ phases })]; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('Phases:'); + expect(output).toContain('DNS='); + expect(output).toContain('TTFB='); + }); + + it('omits phases line when request has no timing data', function () { + const result = makeResult(); + result.requests = [makeRequest({ phases: {} })]; + + const output = formatThreadNetworkResult(result); + + expect(output).not.toContain('Phases:'); + }); + + it('shows HTTP status and version when present', function () { + const result = makeResult(); + result.requests = [makeRequest({ httpStatus: 200, httpVersion: 'h2' })]; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('200'); + expect(output).toContain('h2'); + }); + + it('shows ??? for missing HTTP status', function () { + const result = makeResult(); + result.requests = [makeRequest()]; // no httpStatus + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('???'); + }); + + it('shows "No network requests" message when requests list is empty', function () { + const result = makeResult({ + totalRequestCount: 5, + filteredRequestCount: 0, + filters: { searchString: 'no-match' }, + }); + result.requests = []; + + const output = formatThreadNetworkResult(result); + + expect(output).toContain('No network requests'); + }); +}); diff --git a/src/profile-query/formatters/marker-info.ts b/src/profile-query/formatters/marker-info.ts index edee7d302f..cb83a08072 100644 --- a/src/profile-query/formatters/marker-info.ts +++ b/src/profile-query/formatters/marker-info.ts @@ -36,11 +36,16 @@ import type { MarkerInfoResult, StackTraceData, ThreadMarkersResult, + ThreadNetworkResult, + NetworkRequestEntry, + NetworkPhaseTimings, MarkerGroupData, DurationStats, RateStats, MarkerFilterOptions, } from '../types'; +import { isNetworkMarker } from 'firefox-profiler/profile-logic/marker-data'; +import type { NetworkPayload } from 'firefox-profiler/types/markers'; /** * Aggregated statistics for a group of markers. @@ -1528,3 +1533,190 @@ export function formatMarkerInfo( return lines.join('\n'); } + +function buildNetworkPhases(data: NetworkPayload): NetworkPhaseTimings { + const phases: NetworkPhaseTimings = {}; + if ( + data.domainLookupStart !== undefined && + data.domainLookupEnd !== undefined + ) { + phases.dns = data.domainLookupEnd - data.domainLookupStart; + } + if (data.connectStart !== undefined && data.tcpConnectEnd !== undefined) { + phases.tcp = data.tcpConnectEnd - data.connectStart; + } + if ( + data.secureConnectionStart !== undefined && + data.secureConnectionStart > 0 && + data.connectEnd !== undefined + ) { + phases.tls = data.connectEnd - data.secureConnectionStart; + } + if (data.requestStart !== undefined && data.responseStart !== undefined) { + phases.ttfb = data.responseStart - data.requestStart; + } + if (data.responseStart !== undefined && data.responseEnd !== undefined) { + phases.download = data.responseEnd - data.responseStart; + } + if (data.responseEnd !== undefined) { + phases.mainThread = data.endTime - data.responseEnd; + } + return phases; +} + +export function collectThreadNetwork( + store: Store, + threadMap: ThreadMap, + threadHandle?: string, + filterOptions: { + searchString?: string; + minDuration?: number; + maxDuration?: number; + limit?: number; + } = {} +): ThreadNetworkResult { + const { searchString, minDuration, maxDuration, limit } = filterOptions; + + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const fullMarkerList = threadSelectors.getFullMarkerList(state); + const allMarkerIndexes = threadSelectors.getFullMarkerListIndexes(state); + + // Filter to completed (STOP) network markers only. + // STOP markers are the merged markers that carry full timing data. + const stopIndexes = allMarkerIndexes.filter((i) => { + const m = fullMarkerList[i]; + if (!isNetworkMarker(m)) { + return false; + } + const data = m.data as NetworkPayload; + return data.status === 'STATUS_STOP'; + }); + const totalRequestCount = stopIndexes.length; + + // Apply filters + let filteredIndexes = stopIndexes; + + if (searchString) { + const lowerSearch = searchString.toLowerCase(); + filteredIndexes = filteredIndexes.filter((i) => { + const data = fullMarkerList[i].data as NetworkPayload; + return data.URI.toLowerCase().includes(lowerSearch); + }); + } + + if (minDuration !== undefined || maxDuration !== undefined) { + filteredIndexes = filteredIndexes.filter((i) => { + const data = fullMarkerList[i].data as NetworkPayload; + const duration = data.endTime - data.startTime; + if (minDuration !== undefined && duration < minDuration) { + return false; + } + if (maxDuration !== undefined && duration > maxDuration) { + return false; + } + return true; + }); + } + + const filteredRequestCount = filteredIndexes.length; + + // Accumulate summary stats across all filtered requests (before limit) + const phaseTotals: NetworkPhaseTimings = {}; + let cacheHit = 0; + let cacheMiss = 0; + let cacheUnknown = 0; + + for (const i of filteredIndexes) { + const data = fullMarkerList[i].data as NetworkPayload; + const cache = data.cache; + if (cache === 'Hit' || cache === 'MemoryHit' || cache === 'Prefetched') { + cacheHit++; + } else if ( + cache === 'Miss' || + cache === 'Unresolved' || + cache === 'DiskStorage' || + cache === 'Push' + ) { + cacheMiss++; + } else { + cacheUnknown++; + } + + const phases = buildNetworkPhases(data); + if (phases.dns !== undefined) { + phaseTotals.dns = (phaseTotals.dns ?? 0) + phases.dns; + } + if (phases.tcp !== undefined) { + phaseTotals.tcp = (phaseTotals.tcp ?? 0) + phases.tcp; + } + if (phases.tls !== undefined) { + phaseTotals.tls = (phaseTotals.tls ?? 0) + phases.tls; + } + if (phases.ttfb !== undefined) { + phaseTotals.ttfb = (phaseTotals.ttfb ?? 0) + phases.ttfb; + } + if (phases.download !== undefined) { + phaseTotals.download = (phaseTotals.download ?? 0) + phases.download; + } + if (phases.mainThread !== undefined) { + phaseTotals.mainThread = + (phaseTotals.mainThread ?? 0) + phases.mainThread; + } + } + + // Apply limit after accumulating summary stats. + // limit === 0 means "show all" (no limit). + const limitedIndexes = + limit !== undefined && limit > 0 + ? filteredIndexes.slice(0, limit) + : filteredIndexes; + + // Build per-request entries + const requests: NetworkRequestEntry[] = limitedIndexes.map((i) => { + const data = fullMarkerList[i].data as NetworkPayload; + const duration = data.endTime - data.startTime; + + return { + url: data.URI, + httpStatus: data.responseStatus, + httpVersion: data.httpVersion, + cacheStatus: data.cache, + transferSizeKB: data.count !== undefined ? data.count / 1024 : undefined, + startTime: data.startTime, + duration, + phases: buildNetworkPhases(data), + }; + }); + + const displayThreadHandle = + threadHandle ?? threadMap.handleForThreadIndexes(threadIndexes); + + return { + type: 'thread-network', + threadHandle: displayThreadHandle, + friendlyThreadName, + totalRequestCount, + filteredRequestCount, + filters: + searchString !== undefined || + minDuration !== undefined || + maxDuration !== undefined || + limit !== undefined + ? { searchString, minDuration, maxDuration, limit } + : undefined, + summary: { + cacheHit, + cacheMiss, + cacheUnknown, + phaseTotals, + }, + requests, + }; +} diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index c6bdd87a68..4f797ac064 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -51,6 +51,7 @@ import { } from './formatters/thread-info'; import { collectThreadMarkers, + collectThreadNetwork, collectMarkerStack, collectMarkerInfo, } from './formatters/marker-info'; @@ -71,6 +72,7 @@ import type { ThreadSamplesTopDownResult, ThreadSamplesBottomUpResult, ThreadMarkersResult, + ThreadNetworkResult, ThreadFunctionsResult, MarkerFilterOptions, FunctionFilterOptions, @@ -914,6 +916,27 @@ export class ProfileQuerier { return { ...result, context: this._getContext() }; } + /** + * List completed network requests for a thread with timing phases. + */ + async threadNetwork( + threadHandle?: string, + filterOptions?: { + searchString?: string; + minDuration?: number; + maxDuration?: number; + limit?: number; + } + ): Promise> { + const result = collectThreadNetwork( + this._store, + this._threadMap, + threadHandle, + filterOptions + ); + return { ...result, context: this._getContext() }; + } + /** * List all functions for a thread with their CPU percentages. * Supports filtering by search string, minimum self time, and limit. diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 0d3c933fe7..f64787646f 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -305,6 +305,47 @@ export type CallTreeNode = FunctionDisplayInfo & { }; }; +export type NetworkPhaseTimings = { + dns?: number; + tcp?: number; + tls?: number; + ttfb?: number; + download?: number; + mainThread?: number; +}; + +export type NetworkRequestEntry = { + url: string; + httpStatus?: number; + httpVersion?: string; + cacheStatus?: string; + transferSizeKB?: number; + startTime: number; + duration: number; + phases: NetworkPhaseTimings; +}; + +export type ThreadNetworkResult = { + type: 'thread-network'; + threadHandle: string; + friendlyThreadName: string; + totalRequestCount: number; + filteredRequestCount: number; + filters?: { + searchString?: string; + minDuration?: number; + maxDuration?: number; + limit?: number; + }; + summary: { + cacheHit: number; + cacheMiss: number; + cacheUnknown: number; + phaseTotals: NetworkPhaseTimings; + }; + requests: NetworkRequestEntry[]; +}; + export type ThreadMarkersResult = { type: 'thread-markers'; threadHandle: string; diff --git a/src/test/unit/profile-query/marker-info-formatters.test.ts b/src/test/unit/profile-query/marker-info-formatters.test.ts index 5bec528b17..1ef36d0f54 100644 --- a/src/test/unit/profile-query/marker-info-formatters.test.ts +++ b/src/test/unit/profile-query/marker-info-formatters.test.ts @@ -6,6 +6,7 @@ import { collectMarkerInfo, collectMarkerStack, collectThreadMarkers, + collectThreadNetwork, formatMarkerInfo, formatMarkerStackFull, formatThreadMarkers, @@ -15,7 +16,9 @@ import { ThreadMap } from 'firefox-profiler/profile-query/thread-map'; import { getProfileWithMarkers, getProfileFromTextSamples, + getNetworkMarkers, } from '../../fixtures/profiles/processed-profile'; +import type { NetworkMarkersOptions } from '../../fixtures/profiles/processed-profile'; import { storeWithProfile } from '../../fixtures/stores'; import { StringTable } from 'firefox-profiler/utils/string-table'; import { INTERVAL } from 'firefox-profiler/app-logic/constants'; @@ -431,3 +434,362 @@ describe('collectMarkerStack and formatMarkerStackFull', function () { }); }); }); + +describe('collectThreadNetwork', function () { + function setupWithNetworkMarkers( + options: Array> + ) { + const markers = options.flatMap((o) => getNetworkMarkers(o)); + const profile = getProfileWithMarkers(markers); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + threadMap.handleForThreadIndex(0); + return { store, threadMap }; + } + + it('counts only STATUS_STOP markers, ignoring STATUS_START', function () { + // getNetworkMarkers produces a START + STOP pair per entry + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + uri: 'https://example.com/a', + startTime: 0, + fetchStart: 1, + endTime: 5, + }, + { + id: 2, + uri: 'https://example.com/b', + startTime: 6, + fetchStart: 7, + endTime: 10, + }, + ]); + + const result = collectThreadNetwork(store, threadMap); + + expect(result.totalRequestCount).toBe(2); + expect(result.requests).toHaveLength(2); + }); + + it('filters by searchString case-insensitively', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + uri: 'https://api.example.com/data', + startTime: 0, + fetchStart: 1, + endTime: 5, + }, + { + id: 2, + uri: 'https://static.example.com/img.png', + startTime: 6, + fetchStart: 7, + endTime: 10, + }, + { + id: 3, + uri: 'https://api.example.com/users', + startTime: 11, + fetchStart: 12, + endTime: 15, + }, + ]); + + const result = collectThreadNetwork(store, threadMap, undefined, { + searchString: 'API', + }); + + expect(result.totalRequestCount).toBe(3); + expect(result.filteredRequestCount).toBe(2); + expect(result.requests.every((r) => r.url.includes('api'))).toBe(true); + }); + + it('filters by minDuration', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + uri: 'https://example.com/fast', + startTime: 0, + fetchStart: 0, + endTime: 1, + }, + { + id: 2, + uri: 'https://example.com/slow', + startTime: 2, + fetchStart: 2, + endTime: 10, + }, + ]); + + const result = collectThreadNetwork(store, threadMap, undefined, { + minDuration: 5, + }); + + expect(result.filteredRequestCount).toBe(1); + expect(result.requests[0].url).toContain('slow'); + }); + + it('filters by maxDuration', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + uri: 'https://example.com/fast', + startTime: 0, + fetchStart: 0, + endTime: 1, + }, + { + id: 2, + uri: 'https://example.com/slow', + startTime: 2, + fetchStart: 2, + endTime: 10, + }, + ]); + + const result = collectThreadNetwork(store, threadMap, undefined, { + maxDuration: 5, + }); + + expect(result.filteredRequestCount).toBe(1); + expect(result.requests[0].url).toContain('fast'); + }); + + it('limit restricts the requests list but summary stats cover all filtered results', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + uri: 'https://example.com/a', + startTime: 0, + fetchStart: 0, + endTime: 5, + }, + { + id: 2, + uri: 'https://example.com/b', + startTime: 6, + fetchStart: 6, + endTime: 11, + }, + { + id: 3, + uri: 'https://example.com/c', + startTime: 12, + fetchStart: 12, + endTime: 17, + }, + ]); + + const result = collectThreadNetwork(store, threadMap, undefined, { + limit: 2, + }); + + expect(result.filteredRequestCount).toBe(3); + expect(result.requests).toHaveLength(2); + expect(result.summary.cacheUnknown).toBe(3); // all 3 counted, not just 2 + }); + + it('limit 0 means no limit — all requests are returned', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { id: 1, startTime: 0, fetchStart: 0, endTime: 5 }, + { id: 2, startTime: 6, fetchStart: 6, endTime: 11 }, + { id: 3, startTime: 12, fetchStart: 12, endTime: 17 }, + ]); + + const result = collectThreadNetwork(store, threadMap, undefined, { + limit: 0, + }); + + expect(result.requests).toHaveLength(3); + }); + + it('accumulates cache stats correctly', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + startTime: 0, + fetchStart: 0, + endTime: 1, + payload: { cache: 'Hit' }, + }, + { + id: 2, + startTime: 2, + fetchStart: 2, + endTime: 3, + payload: { cache: 'MemoryHit' }, + }, + { + id: 3, + startTime: 4, + fetchStart: 4, + endTime: 5, + payload: { cache: 'Prefetched' }, + }, + { + id: 4, + startTime: 6, + fetchStart: 6, + endTime: 7, + payload: { cache: 'Miss' }, + }, + { + id: 5, + startTime: 8, + fetchStart: 8, + endTime: 9, + payload: { cache: 'DiskStorage' }, + }, + { id: 6, startTime: 10, fetchStart: 10, endTime: 11 }, // no cache → unknown + ]); + + const result = collectThreadNetwork(store, threadMap); + + expect(result.summary.cacheHit).toBe(3); // Hit + MemoryHit + Prefetched + expect(result.summary.cacheMiss).toBe(2); // Miss + DiskStorage + expect(result.summary.cacheUnknown).toBe(1); // no cache field + }); + + it('extracts phase timings per request', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + startTime: 0, + fetchStart: 0, + endTime: 100, + payload: { + domainLookupStart: 0, + domainLookupEnd: 5, // dns = 5 + connectStart: 5, + tcpConnectEnd: 15, // tcp = 10 + requestStart: 20, + responseStart: 50, // ttfb = 30 + responseEnd: 80, // download = 30; mainThread = 100 - 80 = 20 + }, + }, + ]); + + const result = collectThreadNetwork(store, threadMap); + const phases = result.requests[0].phases; + + expect(phases.dns).toBe(5); + expect(phases.tcp).toBe(10); + expect(phases.ttfb).toBe(30); + expect(phases.download).toBe(30); + expect(phases.mainThread).toBe(20); + expect(phases.tls).toBeUndefined(); + }); + + it('extracts TLS phase only when secureConnectionStart > 0', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + startTime: 0, + fetchStart: 0, + endTime: 50, + payload: { + connectStart: 5, + tcpConnectEnd: 10, + secureConnectionStart: 10, + connectEnd: 18, // tls = 18 - 10 = 8 + }, + }, + ]); + + const result = collectThreadNetwork(store, threadMap); + + expect(result.requests[0].phases.tls).toBe(8); + }); + + it('skips TLS phase when secureConnectionStart is 0', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + startTime: 0, + fetchStart: 0, + endTime: 50, + payload: { + secureConnectionStart: 0, // 0 means no TLS + connectEnd: 10, + }, + }, + ]); + + const result = collectThreadNetwork(store, threadMap); + + expect(result.requests[0].phases.tls).toBeUndefined(); + }); + + it('accumulates phase totals in summary across all filtered requests', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + startTime: 0, + fetchStart: 0, + endTime: 20, + payload: { requestStart: 0, responseStart: 8 }, // ttfb = 8 + }, + { + id: 2, + startTime: 21, + fetchStart: 21, + endTime: 41, + payload: { requestStart: 21, responseStart: 33 }, // ttfb = 12 + }, + ]); + + const result = collectThreadNetwork(store, threadMap); + + expect(result.summary.phaseTotals.ttfb).toBe(20); // 8 + 12 + }); + + it('sets filters field only when at least one filter is applied', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { id: 1, startTime: 0, fetchStart: 0, endTime: 5 }, + ]); + + const noFilters = collectThreadNetwork(store, threadMap); + const withFilter = collectThreadNetwork(store, threadMap, undefined, { + searchString: 'example', + }); + + expect(noFilters.filters).toBeUndefined(); + expect(withFilter.filters).toBeDefined(); + expect(withFilter.filters?.searchString).toBe('example'); + }); + + it('returns zero requests when no markers match filters', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + uri: 'https://example.com/', + startTime: 0, + fetchStart: 0, + endTime: 5, + }, + ]); + + const result = collectThreadNetwork(store, threadMap, undefined, { + searchString: 'no-match-here', + }); + + expect(result.totalRequestCount).toBe(1); + expect(result.filteredRequestCount).toBe(0); + expect(result.requests).toHaveLength(0); + }); + + it('returns correct duration on each request entry', function () { + // The merged marker sets data.startTime to the START marker's table time (0), + // so total duration = endTime - startTime = 25 - 0 = 25. + const { store, threadMap } = setupWithNetworkMarkers([ + { id: 1, startTime: 0, fetchStart: 5, endTime: 25 }, + ]); + + const result = collectThreadNetwork(store, threadMap); + + expect(result.requests[0].duration).toBe(25); + }); +}); From 5f0130e679083a283e145a90361c9286702edd51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Tue, 14 Apr 2026 23:05:13 +0200 Subject: [PATCH 47/63] Add etld+1 to profile info --- profiler-cli/src/formatters.ts | 3 ++- src/profile-query/formatters/profile-info.ts | 4 +++- src/profile-query/process-thread-list.ts | 1 + src/profile-query/types.ts | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 0e0f07ebdf..e171eeadfb 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -397,7 +397,8 @@ Name: ${result.name}\n`; } } - output += ` p-${process.processIndex}: ${process.name} [pid ${process.pid}]${timingInfo} - ${process.cpuMs.toFixed(3)}ms\n`; + const etld1Suffix = process.etld1 ? ` [${process.etld1}]` : ''; + output += ` p-${process.processIndex}: ${process.name}${etld1Suffix} [pid ${process.pid}]${timingInfo} - ${process.cpuMs.toFixed(3)}ms\n`; for (const thread of process.threads) { output += ` ${thread.threadHandle}: ${thread.name} [tid ${thread.tid}] - ${thread.cpuMs.toFixed(3)}ms\n`; diff --git a/src/profile-query/formatters/profile-info.ts b/src/profile-query/formatters/profile-info.ts index 276552ea36..1cce4668d4 100644 --- a/src/profile-query/formatters/profile-info.ts +++ b/src/profile-query/formatters/profile-info.ts @@ -86,7 +86,7 @@ export function collectProfileInfo( showAll || search !== undefined ); - // Apply process names and timing from the profile + // Apply process names, eTLD+1, and timing from the profile result.processes.forEach((processItem) => { const threadFromProcess = profile.threads.find( (t) => t.pid === processItem.pid @@ -96,6 +96,7 @@ export function collectProfileInfo( threadFromProcess.processName || threadFromProcess.processType || 'unknown'; + processItem.etld1 = threadFromProcess['eTLD+1']; processItem.startTime = threadFromProcess.processStartupTime; processItem.endTime = threadFromProcess.processShutdownTime; } @@ -126,6 +127,7 @@ export function collectProfileInfo( processIndex: processItem.processIndex, pid: processItem.pid, name: processItem.name, + etld1: processItem.etld1, cpuMs: processItem.cpuMs, startTime: processItem.startTime, startTimeName, diff --git a/src/profile-query/process-thread-list.ts b/src/profile-query/process-thread-list.ts index 024a6804cc..0c9ae36b9c 100644 --- a/src/profile-query/process-thread-list.ts +++ b/src/profile-query/process-thread-list.ts @@ -27,6 +27,7 @@ export type ProcessListItem = { processIndex: number; pid: string; name: string; + etld1?: string; cpuMs: number; threads: Array<{ threadIndex: number; diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index f64787646f..0367c12278 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -493,6 +493,7 @@ export type ProfileInfoResult = { processIndex: number; pid: string; name: string; + etld1?: string; cpuMs: number; startTime?: number; startTimeName?: string; From 0df676715273d321f949f010ac97bdb64e62bff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 15 Apr 2026 00:42:00 +0200 Subject: [PATCH 48/63] Add a `profiler-cli function annotate` command to show source and asm --- profiler-cli/README.md | 1 + profiler-cli/guide.txt | 22 +- profiler-cli/src/commands/function.ts | 39 ++ profiler-cli/src/daemon.ts | 10 + profiler-cli/src/formatters.ts | 113 +++++ profiler-cli/src/output.ts | 3 + profiler-cli/src/protocol.ts | 13 +- src/profile-query/index.ts | 390 +++++++++++++++++- src/profile-query/types.ts | 54 +++ .../profile-querier-annotate.test.ts | 293 +++++++++++++ 10 files changed, 928 insertions(+), 10 deletions(-) create mode 100644 src/test/unit/profile-query/profile-querier-annotate.test.ts diff --git a/profiler-cli/README.md b/profiler-cli/README.md index 376e9abab4..e7a16bd0d7 100644 --- a/profiler-cli/README.md +++ b/profiler-cli/README.md @@ -40,6 +40,7 @@ profiler-cli marker info # Show detailed marker information (e profiler-cli marker stack # Show full stack trace for a marker profiler-cli function expand # Show full untruncated function name (e.g., f-123) profiler-cli function info # Show detailed function information +profiler-cli function annotate # Show annotated source/assembly with timing data [--mode src|asm|all] [--context 2|file|N] [--symbol-server ] profiler-cli zoom push # Push a zoom range (e.g., 2.7,3.1 or ts-g,ts-G or m-158) profiler-cli zoom pop # Pop the most recent zoom range profiler-cli zoom clear # Clear all zoom ranges (return to full profile) diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index 7d52091189..6f134250ea 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -69,10 +69,14 @@ CORE WORKFLOW profiler-cli thread network --limit 50 Show up to 50 requests Step 6: Drill into specifics - profiler-cli marker info m-1234 Full details for a marker (from handles in marker list) - profiler-cli marker stack m-1234 Full stack trace at the time of a marker - profiler-cli function info f-12 Function details (source location, library) - profiler-cli function expand f-12 Show full untruncated function name + profiler-cli marker info m-1234 Full details for a marker (from handles in marker list) + profiler-cli marker stack m-1234 Full stack trace at the time of a marker + profiler-cli function info f-12 Function details (source location, library) + profiler-cli function expand f-12 Show full untruncated function name + profiler-cli function annotate f-12 Annotated source with per-line self/total timing + profiler-cli function annotate f-12 --mode asm Annotated assembly (requires local symbol server) + profiler-cli function annotate f-12 --mode all Both source and assembly + profiler-cli function annotate f-12 --context file Show entire source file instead of snippets Step 7: Filter to focus the analysis (see FILTERS section below) @@ -240,9 +244,13 @@ COMMON ANALYSIS PATTERNS profiler-cli thread markers --search Reflow --min-duration 5 Deep dive on a specific function: - profiler-cli thread samples Find hot function, note its handle (f-12) - profiler-cli function info f-12 See callers, callees, source location - profiler-cli function expand f-12 See full name if truncated + profiler-cli thread samples Find hot function, note its handle (f-12) + profiler-cli function info f-12 See callers, callees, source location + profiler-cli function expand f-12 See full name if truncated + profiler-cli function annotate f-12 Annotated source: per-line self/total timing + profiler-cli function annotate f-12 --context file Full source file with all timings inline + profiler-cli function annotate f-12 --mode asm Annotated assembly (needs local symbol server) + profiler-cli function annotate f-12 --mode all Source + assembly together Group markers by event type: profiler-cli thread markers --search DOMEvent --group-by field:eventType diff --git a/profiler-cli/src/commands/function.ts b/profiler-cli/src/commands/function.ts index fff8d4c877..63c9442592 100644 --- a/profiler-cli/src/commands/function.ts +++ b/profiler-cli/src/commands/function.ts @@ -46,4 +46,43 @@ export function registerFunctionCommand( ); console.log(formatOutput(result, opts.json ?? false)); }); + + addGlobalOptions( + fn + .command('annotate [handle]') + .description( + 'Show annotated source/assembly with timing data (e.g. f-123)' + ) + .option('--function ', 'Function handle') + .option( + '--mode ', + 'Annotation mode: src, asm, or all (default: src)', + 'src' + ) + .option( + '--symbol-server ', + 'Symbol server URL for asm mode (default: http://localhost:3000)', + 'http://localhost:3000' + ) + .option( + '--context ', + 'Source context: number of lines around annotated lines, or "file" for the whole file (default: 2)', + '2' + ) + ).action(async (handleArg: string | undefined, opts) => { + const funcHandle = handleArg ?? opts.function; + const result = await sendCommand( + sessionDir, + { + command: 'function', + subcommand: 'annotate', + function: funcHandle, + annotateMode: opts.mode, + symbolServerUrl: opts.symbolServer, + annotateContext: opts.context, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); } diff --git a/profiler-cli/src/daemon.ts b/profiler-cli/src/daemon.ts index 1277844e4f..1740f92311 100644 --- a/profiler-cli/src/daemon.ts +++ b/profiler-cli/src/daemon.ts @@ -379,6 +379,16 @@ export class Daemon { return this.querier!.functionExpand(command.function); case 'select': throw new Error('unimplemented'); + case 'annotate': + if (!command.function) { + throw new Error('function handle required for function annotate'); + } + return this.querier!.functionAnnotate( + command.function, + command.annotateMode ?? 'src', + command.symbolServerUrl ?? 'http://localhost:3000', + command.annotateContext ?? '2' + ); default: throw assertExhaustiveCheck(command); } diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index e171eeadfb..c72dab5f4b 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -13,6 +13,7 @@ import type { WithContext, FunctionExpandResult, FunctionInfoResult, + FunctionAnnotateResult, ViewRangeResult, FilterStackResult, ThreadInfoResult, @@ -1144,3 +1145,115 @@ export function formatThreadNetworkResult( return lines.join('\n'); } + +export function formatFunctionAnnotateResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + const out: string[] = []; + const RULER = '─'.repeat(80); + + out.push(contextHeader, ''); + out.push(`Function ${result.functionHandle}: ${result.name}`); + out.push(`Thread: ${result.friendlyThreadName} (${result.threadHandle})`, ''); + out.push( + `Self time: ${Math.round(result.totalSelfSamples)} samples, ` + + `Total time: ${Math.round(result.totalTotalSamples)} samples` + ); + out.push(`Mode: ${result.mode}`); + + for (const w of result.warnings) { + out.push('', `Warning: ${w}`); + } + + // Source annotation + const src = result.srcAnnotation; + if (src) { + const fileSuffix = + src.totalFileLines !== null ? ` (${src.totalFileLines} lines)` : ''; + out.push('', `Source file: ${src.filename}${fileSuffix}`); + out.push( + ` ${Math.round(src.samplesWithLineInfo)} of ${Math.round(src.samplesWithFunction)} ` + + `samples have line number information` + ); + out.push(` Showing: ${src.contextMode}`, ''); + + const W_LINE = 5; + const W_SELF = 6; + const W_TOTAL = 7; + + out.push( + `${'Line'.padStart(W_LINE)} ${'Self'.padStart(W_SELF)} ${'Total'.padStart(W_TOTAL)} Source` + ); + out.push(RULER); + + const showGaps = src.contextMode !== 'full file'; + let prevLine: number | null = null; + for (const line of src.lines) { + if (showGaps && prevLine !== null && line.lineNumber > prevLine + 1) { + out.push(' '.repeat(W_LINE + 2) + '...'); + } + prevLine = line.lineNumber; + + const selfStr = + line.selfSamples > 0 + ? String(Math.round(line.selfSamples)).padStart(W_SELF) + : ' '.repeat(W_SELF); + const totalStr = + line.totalSamples > 0 + ? String(Math.round(line.totalSamples)).padStart(W_TOTAL) + : ' '.repeat(W_TOTAL); + const srcText = line.sourceText !== null ? ` ${line.sourceText}` : ''; + out.push( + `${String(line.lineNumber).padStart(W_LINE)} ${selfStr} ${totalStr}${srcText}` + ); + } + } + + // Assembly annotations + for (const asm of result.asmAnnotations) { + out.push('', `Compilation ${asm.compilationIndex}:`); + out.push(` Name: ${asm.symbolName}`); + out.push(` Address: 0x${asm.symbolAddress.toString(16)}`); + if (asm.functionSize !== null) { + out.push(` Function size: ${asm.functionSize} bytes`); + } + out.push(` Native symbols: ${asm.nativeSymbolCount}`); + + if (asm.fetchError !== null) { + out.push(` (Assembly unavailable: ${asm.fetchError})`); + continue; + } + + out.push(''); + out.push( + ` ${'Address'.padEnd(18)}${'Self'.padStart(6)} ${'Total'.padStart(7)} Instruction` + ); + out.push(' ' + '─'.repeat(70)); + + for (const instr of asm.instructions) { + const addrStr = `0x${instr.address.toString(16)}`.padEnd(18); + const selfStr = + instr.selfSamples > 0 + ? String(Math.round(instr.selfSamples)).padStart(6) + : ' '.repeat(6); + const totalStr = + instr.totalSamples > 0 + ? String(Math.round(instr.totalSamples)).padStart(7) + : ' '.repeat(7); + out.push(` ${addrStr}${selfStr} ${totalStr} ${instr.decodedString}`); + } + } + + if ( + result.srcAnnotation && + result.srcAnnotation.contextMode !== 'full file' + ) { + out.push( + '', + `Tip: use --context file to show the full source file, or --context for more context lines.` + ); + } + + return out.join('\n'); +} diff --git a/profiler-cli/src/output.ts b/profiler-cli/src/output.ts index 2ecaf24709..c16c2f405f 100644 --- a/profiler-cli/src/output.ts +++ b/profiler-cli/src/output.ts @@ -12,6 +12,7 @@ import { formatStatusResult, formatFunctionExpandResult, formatFunctionInfoResult, + formatFunctionAnnotateResult, formatViewRangeResult, formatFilterStackResult, formatThreadInfoResult, @@ -54,6 +55,8 @@ export function formatOutput( return formatFunctionExpandResult(result); case 'function-info': return formatFunctionInfoResult(result); + case 'function-annotate': + return formatFunctionAnnotateResult(result); case 'view-range': return formatViewRangeResult(result); case 'thread-info': diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts index 37467e8e7e..69102a6d8e 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -19,6 +19,8 @@ export type { StatusResult, FunctionExpandResult, FunctionInfoResult, + FunctionAnnotateResult, + AnnotateMode, ViewRangeResult, ThreadInfoResult, ThreadSamplesResult, @@ -50,6 +52,8 @@ import type { StatusResult, FunctionExpandResult, FunctionInfoResult, + FunctionAnnotateResult, + AnnotateMode, ViewRangeResult, ThreadInfoResult, MarkerStackResult, @@ -111,8 +115,12 @@ export type ClientCommand = | { command: 'sample'; subcommand: 'info' | 'select'; sample?: string } | { command: 'function'; - subcommand: 'info' | 'select' | 'expand'; + subcommand: 'info' | 'select' | 'expand' | 'annotate'; function?: string; + annotateMode?: AnnotateMode; + symbolServerUrl?: string; + /** "file", "function", or a number of context lines (e.g. "2") */ + annotateContext?: string; } | { command: 'zoom'; @@ -153,7 +161,8 @@ export type CommandResult = | WithContext | WithContext | WithContext - | WithContext; + | WithContext + | WithContext; export interface SessionMetadata { id: string; diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 4f797ac064..138bade77e 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -57,12 +57,32 @@ import { } from './formatters/marker-info'; import { parseTimeValue } from './time-range-parser'; import { FilterStack, pushSpecTransforms } from './filter-stack'; +import { + getStackLineInfo, + getLineTimings, +} from 'firefox-profiler/profile-logic/line-timings'; +import { + getStackAddressInfo, + getAddressTimings, +} from 'firefox-profiler/profile-logic/address-timings'; +import { fetchAssembly } from 'firefox-profiler/utils/fetch-assembly'; +import { fetchSource } from 'firefox-profiler/utils/fetch-source'; +import type { ExternalCommunicationDelegate } from 'firefox-profiler/utils/query-api'; +import type { + AddressProof, + StartEndRange, + ThreadIndex, +} from 'firefox-profiler/types'; import type { StatusResult, SessionContext, WithContext, FunctionExpandResult, FunctionInfoResult, + FunctionAnnotateResult, + AnnotateMode, + FunctionSourceAnnotation, + FunctionAsmAnnotation, ViewRangeResult, ThreadInfoResult, MarkerStackResult, @@ -82,9 +102,29 @@ import type { import type { CallTreeCollectionOptions } from './formatters/call-tree'; import { getThreadsKey } from 'firefox-profiler/profile-logic/profile-data'; -import type { StartEndRange, ThreadIndex } from 'firefox-profiler/types'; import type { Store } from '../types/store'; +class NodeExternalCommunicationDelegate implements ExternalCommunicationDelegate { + async fetchUrlResponse(url: string, postData?: string): Promise { + const init: RequestInit = + postData !== undefined ? { method: 'POST', body: postData } : {}; + return fetch(url, init); + } + + async queryBrowserSymbolicationApi( + _path: string, + _requestJson: string + ): Promise { + throw new Error('No browser connection available in profiler-cli'); + } + + async fetchJSSourceFromBrowser(_source: string): Promise { + throw new Error('No browser connection available in profiler-cli'); + } +} + +const nodeDelegate = new NodeExternalCommunicationDelegate(); + export class ProfileQuerier { _store: Store; _processIndexMap: Map; @@ -92,6 +132,7 @@ export class ProfileQuerier { _threadMap: ThreadMap; _markerMap: MarkerMap; _filterStack: FilterStack; + _archiveCache: Map>; constructor(store: Store, rootRange: StartEndRange) { this._store = store; @@ -99,6 +140,7 @@ export class ProfileQuerier { this._timestampManager = new TimestampManager(rootRange); this._threadMap = new ThreadMap(); this._filterStack = new FilterStack(); + this._archiveCache = new Map(); // Build process index map const state = this._store.getState(); @@ -1003,4 +1045,350 @@ export class ProfileQuerier { ); return { ...result, context: this._getContext() }; } + + /** + * Annotate a function with per-line source or per-instruction assembly timing data. + */ + async functionAnnotate( + functionHandle: string, + mode: AnnotateMode, + symbolServerUrl: string, + contextOption: string = '2' + ): Promise> { + const state = this._store.getState(); + const profile = getProfile(state); + const { funcTable, stringArray, resourceTable } = profile.shared; + + const funcIndex = parseFunctionHandle(functionHandle, funcTable.length); + const funcName = stringArray[funcTable.name[funcIndex]]; + const warnings: string[] = []; + + // Resolve library name for fullName + const resourceIndex = funcTable.resource[funcIndex]; + let libraryName: string | undefined; + if (resourceIndex !== -1) { + const libIndex = resourceTable.lib[resourceIndex]; + if ( + libIndex !== null && + libIndex !== undefined && + libIndex >= 0 && + profile.libs + ) { + libraryName = profile.libs[libIndex].name; + } + } + const fullName = libraryName ? `${libraryName}!${funcName}` : funcName; + + // Get selected thread + derived thread data (derived Thread has correct types for utilities) + const threadIndexes = getSelectedThreadIndexes(state); + const threadSelectors = getThreadSelectors(threadIndexes); + const thread = threadSelectors.getFilteredThread(state); + const { + stackTable, + frameTable, + funcTable: threadFuncTable, + nativeSymbols: threadNativeSymbols, + } = thread; + const samples = thread.samples; + + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const threadHandle = this._threadMap.handleForThreadIndexes(threadIndexes); + + // Compute aggregate self/total for the header. + // Build a boolean lookup: does frame i belong to funcIndex? + const frameInFunc = new Uint8Array(frameTable.func.length); + for (let fi = 0; fi < frameTable.func.length; fi++) { + if (frameTable.func[fi] === funcIndex) { + frameInFunc[fi] = 1; + } + } + // Memoize bottom-up: does this stack contain any frame for funcIndex? + // stackTable entries are in topological order (prefix always has lower index). + const stackContainsFunc = new Int8Array(stackTable.length); + for (let si = 0; si < stackTable.length; si++) { + const frame = stackTable.frame[si]; + if (frameInFunc[frame]) { + stackContainsFunc[si] = 1; + } else { + const prefix = stackTable.prefix[si]; + stackContainsFunc[si] = + prefix !== null ? stackContainsFunc[prefix] : -1; + } + } + + let totalSelfSamples = 0; + let totalTotalSamples = 0; + for (let si = 0; si < samples.length; si++) { + const stackIndex = samples.stack[si]; + if (stackIndex === null) { + continue; + } + const weight = samples.weight ? samples.weight[si] : 1; + if (stackContainsFunc[stackIndex] === 1) { + totalTotalSamples += weight; + } + if (frameInFunc[stackTable.frame[stackIndex]]) { + totalSelfSamples += weight; + } + } + + // Source annotation + let srcAnnotation: FunctionSourceAnnotation | null = null; + if (mode === 'src' || mode === 'all') { + const sourceIndex = funcTable.source[funcIndex]; + if (sourceIndex !== null) { + const filenameStrIndex = thread.sources.filename[sourceIndex]; + const filename = thread.stringTable.getString(filenameStrIndex); + const sourceUuid = thread.sources.id[sourceIndex]; + + // getStackLineInfo finds all frames belonging to this source file and + // computes per-line hit sets. getLineTimings aggregates into self/total maps. + const stackLineInfo = getStackLineInfo( + stackTable, + frameTable, + threadFuncTable, + sourceIndex + ); + const { totalLineHits, selfLineHits } = getLineTimings( + stackLineInfo, + samples + ); + + // Count samples with/without line number information + let samplesWithFunction = 0; + let samplesWithLineInfo = 0; + for (let si = 0; si < samples.length; si++) { + const stackIndex = samples.stack[si]; + if (stackIndex === null) { + continue; + } + const lineSetIndex = + stackLineInfo.stackIndexToLineSetIndex[stackIndex]; + if (lineSetIndex === -1) { + continue; + } + const weight = samples.weight ? samples.weight[si] : 1; + samplesWithFunction += weight; + if (stackLineInfo.lineSetTable.self[lineSetIndex] !== -1) { + samplesWithLineInfo += weight; + } + } + + // Build an addressProof from any native symbol for this function. + // This is used by fetchSource to query the /source/v1 API on local symbol servers. + let addressProof: AddressProof | null = null; + for (let fi = 0; fi < frameTable.func.length; fi++) { + if (frameTable.func[fi] === funcIndex) { + const ns = frameTable.nativeSymbol[fi]; + if (ns !== null) { + const libIndex = threadNativeSymbols.libIndex[ns]; + const lib = profile.libs[libIndex]; + if (lib.debugName && lib.breakpadId) { + addressProof = { + debugName: lib.debugName, + breakpadId: lib.breakpadId, + address: threadNativeSymbols.address[ns], + }; + } + break; + } + } + } + + // Fetch source using the same path as the profiler UI: + // tries /source/v1 on local symbol server, CORS download for Mercurial/crates.io, etc. + let fileLines: string[] | null = null; + let totalFileLines: number | null = null; + const fetchResult = await fetchSource( + filename, + sourceUuid, + symbolServerUrl, + addressProof, + this._archiveCache, + nodeDelegate + ); + if (fetchResult.type === 'SUCCESS') { + fileLines = fetchResult.source.split('\n'); + totalFileLines = fileLines.length; + } else { + const errorMessages = fetchResult.errors + .map((e) => JSON.stringify(e)) + .join('; '); + warnings.push( + `Could not fetch source for ${filename}: ${errorMessages}` + ); + } + + // Determine which lines to show based on the context option + const annotatedLineNums = new Set([ + ...totalLineHits.keys(), + ...selfLineHits.keys(), + ]); + let linesToShow: Set; + let contextMode: string; + + if (contextOption === 'file') { + // Show the whole file + linesToShow = new Set(); + const last = totalFileLines ?? Math.max(...annotatedLineNums); + for (let ln = 1; ln <= last; ln++) { + linesToShow.add(ln); + } + contextMode = 'full file'; + } else { + // Treat as a number of context lines (default: 2) + const parsed = parseInt(contextOption, 10); + const CONTEXT = Math.max(0, isNaN(parsed) ? 2 : parsed); + linesToShow = new Set(); + for (const ln of annotatedLineNums) { + for ( + let ctx = Math.max(1, ln - CONTEXT); + ctx <= ln + CONTEXT; + ctx++ + ) { + linesToShow.add(ctx); + } + } + contextMode = + CONTEXT === 0 + ? 'annotated lines only' + : `±${CONTEXT} lines context`; + } + + const sortedLines = Array.from(linesToShow).sort((a, b) => a - b); + srcAnnotation = { + filename, + totalFileLines, + samplesWithFunction, + samplesWithLineInfo, + contextMode, + lines: sortedLines.map((ln) => ({ + lineNumber: ln, + selfSamples: selfLineHits.get(ln) ?? 0, + totalSamples: totalLineHits.get(ln) ?? 0, + sourceText: fileLines !== null ? (fileLines[ln - 1] ?? null) : null, + })), + }; + } else if (mode === 'src') { + warnings.push( + `Function ${functionHandle} has no source index. Use --mode asm for assembly view.` + ); + } + } + + // Assembly annotation + const asmAnnotations: FunctionAsmAnnotation[] = []; + if (mode === 'asm' || mode === 'all') { + // Collect all native symbol indices for this funcIndex by scanning frames + const nativeSymbolsForFunc = new Set(); + for (let fi = 0; fi < frameTable.func.length; fi++) { + if (frameTable.func[fi] === funcIndex) { + const ns = frameTable.nativeSymbol[fi]; + if (ns !== null) { + nativeSymbolsForFunc.add(ns); + } + } + } + + if (nativeSymbolsForFunc.size === 0) { + warnings.push( + `Function ${functionHandle} has no native symbols — may be JS-only or not symbolicated.` + ); + } + + const nativeSymbolCount = nativeSymbolsForFunc.size; + let compilationIndex = 1; + + for (const nsIndex of nativeSymbolsForFunc) { + const symbolName = thread.stringTable.getString( + threadNativeSymbols.name[nsIndex] + ); + const symbolAddress = threadNativeSymbols.address[nsIndex]; + const functionSize = threadNativeSymbols.functionSize[nsIndex] ?? null; + const libIndex = threadNativeSymbols.libIndex[nsIndex]; + const lib = profile.libs[libIndex]; + + // Compute per-address timings using the existing utilities + const stackAddressInfo = getStackAddressInfo( + stackTable, + frameTable, + threadFuncTable, + nsIndex + ); + const { totalAddressHits, selfAddressHits } = getAddressTimings( + stackAddressInfo, + samples + ); + + const nativeSymbolInfo = { + name: symbolName, + address: symbolAddress, + functionSize: functionSize ?? 0, + functionSizeIsKnown: functionSize !== null, + libIndex, + }; + + let fetchError: string | null = null; + let instructions: FunctionAsmAnnotation['instructions'] = []; + + try { + const fetchResult = await fetchAssembly( + nativeSymbolInfo, + lib, + symbolServerUrl, + nodeDelegate + ); + if (fetchResult.type === 'SUCCESS') { + instructions = fetchResult.instructions.map((instr) => ({ + address: instr.address, + selfSamples: selfAddressHits.get(instr.address) ?? 0, + totalSamples: totalAddressHits.get(instr.address) ?? 0, + decodedString: instr.decodedString, + })); + } else { + fetchError = fetchResult.errors + .map((e) => JSON.stringify(e)) + .join('; '); + warnings.push( + `Assembly fetch failed for ${symbolName}: ${fetchError}` + ); + } + } catch (e) { + fetchError = e instanceof Error ? e.message : String(e); + warnings.push( + `Assembly fetch threw for ${symbolName}: ${fetchError}` + ); + } + + asmAnnotations.push({ + compilationIndex, + symbolName, + symbolAddress, + functionSize, + nativeSymbolCount, + fetchError, + instructions, + }); + compilationIndex++; + } + } + + const annotateResult: FunctionAnnotateResult = { + type: 'function-annotate', + functionHandle, + funcIndex, + name: funcName, + fullName, + threadHandle, + friendlyThreadName, + totalSelfSamples, + totalTotalSamples, + mode, + srcAnnotation, + asmAnnotations, + warnings, + }; + + return { ...annotateResult, context: this._getContext() }; + } } diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 0367c12278..5433a57527 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -164,6 +164,60 @@ export type FunctionInfoResult = { }; }; +// ===== Function Annotate ===== + +export type AnnotateMode = 'src' | 'asm' | 'all'; + +export type AnnotatedSourceLine = { + lineNumber: number; + selfSamples: number; + totalSamples: number; + sourceText: string | null; +}; + +export type FunctionSourceAnnotation = { + filename: string; + totalFileLines: number | null; + samplesWithFunction: number; + samplesWithLineInfo: number; + // Human-readable description of how lines were selected, e.g. "±2 lines context", "full function", "full file" + contextMode: string; + lines: AnnotatedSourceLine[]; +}; + +export type AnnotatedInstruction = { + address: number; + selfSamples: number; + totalSamples: number; + decodedString: string; +}; + +export type FunctionAsmAnnotation = { + compilationIndex: number; + symbolName: string; + symbolAddress: number; + functionSize: number | null; + nativeSymbolCount: number; + fetchError: string | null; + instructions: AnnotatedInstruction[]; +}; + +export type FunctionAnnotateResult = { + type: 'function-annotate'; + functionHandle: string; + funcIndex: number; + name: string; + fullName: string; + threadHandle: string; + friendlyThreadName: string; + totalSelfSamples: number; + totalTotalSamples: number; + mode: AnnotateMode; + srcAnnotation: FunctionSourceAnnotation | null; + asmAnnotations: FunctionAsmAnnotation[]; + warnings: string[]; +}; + // ===== View Range Commands ===== export type ViewRangeResult = { diff --git a/src/test/unit/profile-query/profile-querier-annotate.test.ts b/src/test/unit/profile-query/profile-querier-annotate.test.ts new file mode 100644 index 0000000000..4a8a43ac84 --- /dev/null +++ b/src/test/unit/profile-query/profile-querier-annotate.test.ts @@ -0,0 +1,293 @@ +/* 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/. */ + +/** + * Unit tests for ProfileQuerier.functionAnnotate. + * + * fetchSource and fetchAssembly are mocked because they make network requests. + * + * NOTE on sample layout: _parseTextSamples uses the FIRST row to determine + * column widths. Functions with long names (e.g. A[file:f.c][line:10]) must + * be in row 1 so their column is wide enough. Use single-row samples when the + * function under test should be both root and leaf. + */ + +import { ProfileQuerier } from 'firefox-profiler/profile-query'; +import { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; +import { getProfileRootRange } from 'firefox-profiler/selectors/profile'; +import { storeWithProfile } from '../../fixtures/stores'; + +jest.mock('firefox-profiler/utils/fetch-source'); +jest.mock('firefox-profiler/utils/fetch-assembly'); + +function funcHandle( + funcNamesDictPerThread: Array<{ [name: string]: number }>, + name: string +): string { + return `f-${funcNamesDictPerThread[0][name]}`; +} + +function makeQuerier( + profile: ReturnType['profile'] +) { + const store = storeWithProfile(profile); + return new ProfileQuerier(store, getProfileRootRange(store.getState())); +} + +describe('ProfileQuerier.functionAnnotate', function () { + let fetchSource: jest.Mock; + + beforeEach(function () { + fetchSource = jest.requireMock( + 'firefox-profiler/utils/fetch-source' + ).fetchSource; + fetchSource.mockResolvedValue({ type: 'ERROR', errors: [] }); + }); + + describe('aggregate self/total sample counts', function () { + it('counts self when function is the only frame (root = leaf)', async function () { + // Single-row samples: A is simultaneously root and leaf in all 3 samples. + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:10] A[file:f.c][line:10] A[file:f.c][line:10] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000' + ); + + expect(result.totalSelfSamples).toBe(3); + expect(result.totalTotalSamples).toBe(3); + }); + + it('distinguishes self from total when A is not always the leaf', async function () { + // A must be in row 1 so column widths are determined correctly. + // Sample 1: A@10 (root) → B (leaf) → A.self=0, A.total=1 + // Sample 2: B (root) → A@10 (leaf) → A.self=1, A.total=1 + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:10] B + B A[file:f.c][line:10] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000' + ); + + expect(result.totalSelfSamples).toBe(1); + expect(result.totalTotalSamples).toBe(2); + }); + }); + + describe('src mode - line timings', function () { + it('attributes self and total hits to the correct lines', async function () { + // Single-row samples: A is root and leaf, hits different lines per sample. + // Sample 1: A@line10 → self@10 += 1, total@10 += 1 + // Sample 2: A@line12 → self@12 += 1, total@12 += 1 + // Sample 3: A@line10 → self@10 += 1, total@10 += 1 + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:10] A[file:f.c][line:12] A[file:f.c][line:10] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000' + ); + + const src = result.srcAnnotation; + expect(src).not.toBeNull(); + + const line10 = src!.lines.find((l) => l.lineNumber === 10); + const line12 = src!.lines.find((l) => l.lineNumber === 12); + + expect(line10).toBeDefined(); + expect(line10!.selfSamples).toBe(2); + expect(line10!.totalSamples).toBe(2); + + expect(line12).toBeDefined(); + expect(line12!.selfSamples).toBe(1); + expect(line12!.totalSamples).toBe(1); + }); + + it('separates self (leaf) from total (any stack position) for line hits', async function () { + // Sample 1: A@10 (root) → B (leaf): line10.self=0, line10.total=1 + // Sample 2: B (root) → A@10 (leaf): line10.self=1, line10.total=1 + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:10] B + B A[file:f.c][line:10] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000' + ); + + const src = result.srcAnnotation; + expect(src).not.toBeNull(); + + const line10 = src!.lines.find((l) => l.lineNumber === 10); + expect(line10).toBeDefined(); + expect(line10!.selfSamples).toBe(1); + expect(line10!.totalSamples).toBe(2); + }); + + it('includes source text when fetchSource succeeds', async function () { + fetchSource.mockResolvedValue({ + type: 'SUCCESS', + source: 'line one\nline two\nline three\nline four\nline five', + }); + + // Single-row: A is leaf, hit at line 2. + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:2] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000' + ); + + const src = result.srcAnnotation; + expect(src).not.toBeNull(); + expect(src!.totalFileLines).toBe(5); + + const line2 = src!.lines.find((l) => l.lineNumber === 2); + expect(line2!.sourceText).toBe('line two'); + }); + + it('leaves sourceText null and adds a warning when fetchSource fails', async function () { + fetchSource.mockResolvedValue({ + type: 'ERROR', + errors: [{ type: 'NO_KNOWN_CORS_URL' }], + }); + + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:5] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000' + ); + + const src = result.srcAnnotation; + expect(src).not.toBeNull(); + expect(src!.totalFileLines).toBeNull(); + + const line5 = src!.lines.find((l) => l.lineNumber === 5); + expect(line5!.sourceText).toBeNull(); + expect(result.warnings.some((w) => w.includes('f.c'))).toBe(true); + }); + + it('adds a warning and returns null srcAnnotation when function has no source index', async function () { + // A has no [file:] attribute → funcTable.source[funcIndex] is null + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000' + ); + + expect(result.srcAnnotation).toBeNull(); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toContain('no source index'); + }); + }); + + describe('--context option', function () { + it('shows all lines when context is "file"', async function () { + fetchSource.mockResolvedValue({ + type: 'SUCCESS', + source: Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join( + '\n' + ), + }); + + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:10] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000', + 'file' + ); + + const src = result.srcAnnotation; + expect(src).not.toBeNull(); + expect(src!.contextMode).toBe('full file'); + expect(src!.lines.length).toBe(20); + expect(src!.lines[0].lineNumber).toBe(1); + expect(src!.lines[19].lineNumber).toBe(20); + }); + + it('shows annotated lines ± N context lines when context is a number', async function () { + fetchSource.mockResolvedValue({ + type: 'SUCCESS', + source: Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join( + '\n' + ), + }); + + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:10] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000', + '1' + ); + + const src = result.srcAnnotation; + expect(src).not.toBeNull(); + expect(src!.contextMode).toBe('±1 lines context'); + + const lineNumbers = src!.lines.map((l) => l.lineNumber); + expect(lineNumbers).toContain(9); + expect(lineNumbers).toContain(10); + expect(lineNumbers).toContain(11); + expect(lineNumbers).not.toContain(1); + expect(lineNumbers).not.toContain(20); + }); + + it('shows only annotated lines when context is 0', async function () { + fetchSource.mockResolvedValue({ + type: 'SUCCESS', + source: Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join( + '\n' + ), + }); + + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:f.c][line:10] + `); + + const result = await makeQuerier(profile).functionAnnotate( + funcHandle(funcNamesDictPerThread, 'A'), + 'src', + 'http://localhost:3000', + '0' + ); + + const src = result.srcAnnotation; + expect(src).not.toBeNull(); + expect(src!.contextMode).toBe('annotated lines only'); + + const lineNumbers = src!.lines.map((l) => l.lineNumber); + expect(lineNumbers).toEqual([10]); + }); + }); +}); From b508297190424b988b19f26320d302c5c2c5005a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 15 Apr 2026 13:06:08 +0200 Subject: [PATCH 49/63] Also add pq as a binary so both pq and profiler-cli can be used I've tried to use and love the profiler-cli in the terminal, but it's just so difficult to type compared to pq, and it made me so annoyed. I think it makes sense to publish the package as profiler-cli, but this pq symlink as a shortcut is such a huge productivity boost if you are actually using the tool yourself. --- profiler-cli/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/profiler-cli/package.json b/profiler-cli/package.json index 3f80d99aef..1ca31cd8fd 100644 --- a/profiler-cli/package.json +++ b/profiler-cli/package.json @@ -7,7 +7,8 @@ }, "main": "./dist/profiler-cli.js", "bin": { - "profiler-cli": "./dist/profiler-cli.js" + "profiler-cli": "./dist/profiler-cli.js", + "pq": "./dist/profiler-cli.js" }, "files": [ "dist/profiler-cli.js" From ba7eee3643e06dad9c257fa3b88a6b89f2cf1bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 15 Apr 2026 14:04:20 +0200 Subject: [PATCH 50/63] Make regex or usage clearer in the guide --- profiler-cli/guide.txt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index 6f134250ea..2ea89b50f9 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -50,9 +50,12 @@ CORE WORKFLOW - Matches any frame in the call stack (case-insensitive substring) - Comma joins multiple terms with AND: both must appear somewhere in the stack profiler-cli thread samples-top-down --search "GC,malloc" paths through both GC and malloc - - "|" is a literal character, not OR — it will match nothing unless a function - name actually contains "|" - - To see results matching either of two terms, run two separate searches + - "|" IS A LITERAL CHARACTER, NOT OR. There is no OR operator in --search. + ✗ WRONG: --search "foo|bar" matches nothing (no function is named "foo|bar") + ✗ WRONG: --search "foo\|bar" same — the backslash doesn't help + ✓ RIGHT: run two separate commands, one per term: + profiler-cli thread functions --search "foo" + profiler-cli thread functions --search "bar" Step 5: Analyze markers (browser events, timers, etc.) profiler-cli thread markers All markers with aggregated stats @@ -294,9 +297,9 @@ TIPS - Idle time alone is not a finding: only investigate idle during a period when the thread should be busy (e.g. inside a zoom on a jank marker), which may indicate lock contention or blocking on another thread - - --search with comma is AND, not OR: "--search foo,bar" keeps only samples where - BOTH "foo" and "bar" appear somewhere in the stack; to get OR behavior, run two - separate searches; "|" is a literal character and will match nothing + - --search has no OR operator. "|" and "\|" are literal characters that will match + nothing. "--search foo,bar" is AND (both must appear). To get OR behavior, run two + separate commands: once with "--search foo", once with "--search bar". - Try filters ephemerally first (as flags on thread commands) before committing with "filter push" - "filter push --during-marker --search X" is powerful for correlating CPU work From 810d4953c413ecb594c3b005f04a03c8ab152cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 15 Apr 2026 14:20:01 +0200 Subject: [PATCH 51/63] Add `profiler-cli profile logs` Denis' tool has this feature already. It's good if we support it too since the logs are important for some teams. --- profiler-cli/README.md | 1 + profiler-cli/guide.txt | 8 + profiler-cli/src/commands/profile.ts | 58 ++++++ profiler-cli/src/daemon.ts | 2 + profiler-cli/src/formatters.ts | 43 +++++ profiler-cli/src/output.ts | 3 + profiler-cli/src/protocol.ts | 16 +- src/profile-logic/marker-data.ts | 90 ++++++++++ src/profile-query/formatters/marker-info.ts | 165 +++++++++++++++++- src/profile-query/index.ts | 23 +++ src/profile-query/types.ts | 13 ++ src/test/fixtures/profiles/marker-schema.ts | 2 + .../__snapshots__/profile-view.test.ts.snap | 5 + src/test/unit/window-console.test.ts | 15 +- src/types/markers.ts | 10 ++ src/utils/window-console.ts | 65 +++---- 16 files changed, 469 insertions(+), 50 deletions(-) diff --git a/profiler-cli/README.md b/profiler-cli/README.md index e7a16bd0d7..a678915718 100644 --- a/profiler-cli/README.md +++ b/profiler-cli/README.md @@ -29,6 +29,7 @@ Run `profiler-cli --help` for the full options reference. ```bash profiler-cli load # Start daemon and load profile (file or http/https URL) profiler-cli profile info # Print profile summary [--all] [--search ] +profiler-cli profile logs # Print Log markers in MOZ_LOG format [--thread] [--module] [--level] [--search] [--limit] profiler-cli thread info # Print detailed thread information profiler-cli thread select # Select a thread (e.g., t-0, t-1) profiler-cli thread samples # Show hot functions list for current thread diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index 2ea89b50f9..700c1429e4 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -28,6 +28,7 @@ CORE WORKFLOW profiler-cli profile info Overview: processes, threads, time range, CPU activity profiler-cli profile info --all Show all processes and threads (not just top 5) profiler-cli profile info --search GeckoMain Filter by process/thread name, pid, or tid + profiler-cli profile logs Print all Log markers in MOZ_LOG format (across all threads) Step 3: Select a thread to analyze profiler-cli thread select t-0 Select a specific thread (persists for future commands) @@ -71,6 +72,13 @@ CORE WORKFLOW profiler-cli thread network --max-duration 50 Only requests <= 50ms profiler-cli thread network --limit 50 Show up to 50 requests + profiler-cli profile logs All Log markers in MOZ_LOG format (all threads) + profiler-cli profile logs --module nsHttp Filter by module name (substring match) + profiler-cli profile logs --level info Minimum level: error, warn, info, debug, verbose + profiler-cli profile logs --search "connect" Filter by substring in message + profiler-cli profile logs --thread t-0 Restrict to a specific thread + profiler-cli profile logs --limit 200 Show only the first 200 entries + Step 6: Drill into specifics profiler-cli marker info m-1234 Full details for a marker (from handles in marker list) profiler-cli marker stack m-1234 Full stack trace at the time of a marker diff --git a/profiler-cli/src/commands/profile.ts b/profiler-cli/src/commands/profile.ts index b8d4ec6b6a..e827d06d94 100644 --- a/profiler-cli/src/commands/profile.ts +++ b/profiler-cli/src/commands/profile.ts @@ -41,4 +41,62 @@ export function registerProfileCommand( ); console.log(formatOutput(result, opts.json ?? false)); }); + + const VALID_LOG_LEVELS = ['error', 'warn', 'info', 'debug', 'verbose']; + + addGlobalOptions( + profile + .command('logs') + .description('Print Log markers in MOZ_LOG format') + .option('--thread ', 'Filter to a specific thread (e.g. t-0)') + .option('--module ', 'Filter by module name (substring match)') + .option( + '--level ', + `Minimum log level: ${VALID_LOG_LEVELS.join(', ')}` + ) + .option('--search ', 'Filter by substring in message') + .option('--limit ', 'Limit to first N entries') + ).action(async (opts) => { + if (opts.level !== undefined && !VALID_LOG_LEVELS.includes(opts.level)) { + console.error( + `Error: --level must be one of: ${VALID_LOG_LEVELS.join(', ')}` + ); + process.exit(1); + } + + let limit: number | undefined; + if (opts.limit !== undefined) { + limit = parseInt(opts.limit, 10); + if (isNaN(limit) || limit <= 0) { + console.error('Error: --limit must be a positive integer'); + process.exit(1); + } + } + + const hasFilters = + opts.thread !== undefined || + opts.module !== undefined || + opts.level !== undefined || + opts.search !== undefined || + limit !== undefined; + + const result = await sendCommand( + sessionDir, + { + command: 'profile', + subcommand: 'logs', + logFilters: hasFilters + ? { + thread: opts.thread, + module: opts.module, + level: opts.level, + search: opts.search, + limit, + } + : undefined, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); } diff --git a/profiler-cli/src/daemon.ts b/profiler-cli/src/daemon.ts index 1740f92311..5d10809ef3 100644 --- a/profiler-cli/src/daemon.ts +++ b/profiler-cli/src/daemon.ts @@ -284,6 +284,8 @@ export class Daemon { return this.querier!.profileInfo(command.all, command.search); case 'threads': throw new Error('unimplemented'); + case 'logs': + return this.querier!.profileLogs(command.logFilters); default: throw assertExhaustiveCheck(command); } diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index c72dab5f4b..4039f2dc4f 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -31,6 +31,7 @@ import type { CallTreeNode, FilterEntry, SampleFilterSpec, + ProfileLogsResult, } from './protocol'; import { truncateFunctionName } from '../../src/profile-query/function-list'; import { describeSpec } from '../../src/profile-query/filter-stack'; @@ -1257,3 +1258,45 @@ export function formatFunctionAnnotateResult( return out.join('\n'); } + +export function formatProfileLogsResult( + result: WithContext +): string { + const lines: string[] = [formatContextHeader(result.context), '']; + + const { filters } = result; + const isFiltered = + filters !== undefined && + (filters.thread !== undefined || + filters.module !== undefined || + filters.level !== undefined || + filters.search !== undefined || + filters.limit !== undefined); + + const shown = result.entries.length; + const total = result.totalCount; + + if (total === 0) { + lines.push( + isFiltered + ? 'No log entries match the specified filters.' + : 'No Log markers found in this profile.' + ); + return lines.join('\n'); + } + + if (isFiltered && shown < total) { + lines.push(`Showing ${shown} of ${total} log entries (filtered/limited)`); + } else if (isFiltered) { + lines.push(`${total} log entries (filtered)`); + } else { + lines.push(`${total} log entries`); + } + lines.push(''); + + for (const entry of result.entries) { + lines.push(entry); + } + + return lines.join('\n'); +} diff --git a/profiler-cli/src/output.ts b/profiler-cli/src/output.ts index c16c2f405f..2a726db4a0 100644 --- a/profiler-cli/src/output.ts +++ b/profiler-cli/src/output.ts @@ -25,6 +25,7 @@ import { formatThreadMarkersResult, formatThreadFunctionsResult, formatThreadNetworkResult, + formatProfileLogsResult, } from './formatters'; /** @@ -79,6 +80,8 @@ export function formatOutput( return formatThreadFunctionsResult(result); case 'thread-network': return formatThreadNetworkResult(result); + case 'profile-logs': + return formatProfileLogsResult(result); default: throw assertExhaustiveCheck(result); } diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts index 69102a6d8e..725746fd3f 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -40,6 +40,7 @@ export type { MarkerStackResult, StackTraceData, ProfileInfoResult, + ProfileLogsResult, } from '../../src/profile-query/types'; export type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; @@ -66,6 +67,7 @@ import type { ThreadNetworkResult, ThreadFunctionsResult, FilterStackResult, + ProfileLogsResult, } from '../../src/profile-query/types'; import type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; @@ -81,6 +83,17 @@ export type ClientCommand = all?: boolean; search?: string; } + | { + command: 'profile'; + subcommand: 'logs'; + logFilters?: { + thread?: string; + module?: string; + level?: string; + search?: string; + limit?: number; + }; + } | { command: 'thread'; subcommand: @@ -162,7 +175,8 @@ export type CommandResult = | WithContext | WithContext | WithContext - | WithContext; + | WithContext + | WithContext; export interface SessionMetadata { id: string; diff --git a/src/profile-logic/marker-data.ts b/src/profile-logic/marker-data.ts index 97695c0722..cf3a1f5abe 100644 --- a/src/profile-logic/marker-data.ts +++ b/src/profile-logic/marker-data.ts @@ -41,6 +41,7 @@ import type { MarkerSchemaByName, MarkerDisplayLocation, Tid, + LogMarkerPayload, } from 'firefox-profiler/types'; /** @@ -1583,3 +1584,92 @@ export const stringsToMarkerRegExps = ( fieldMap, }; }; + +// In the new Log marker format, the `level` field is a string table index +// pointing to one of these strings. Map them to the single-letter abbreviations +// used in MOZ_LOG output (E/W/I/D/V). +export const LOG_LEVEL_STRING_TO_LETTER: Record = { + Error: 'E', + Warning: 'W', + Info: 'I', + Debug: 'D', + Verbose: 'V', +}; + +// Maps MOZ_LOG single-letter level abbreviations to a numeric priority +// (lower number = higher severity) for filtering comparisons. +export const LOG_LETTER_TO_LEVEL: Record = { + E: 1, + W: 2, + I: 3, + D: 4, + V: 5, +}; + +/** + * Format an absolute timestamp (ms since epoch) as a MOZ_LOG date string. + * Matches the output format of mozlog: "YYYY-MM-DD HH:MM:SS.mssμs UTC" + */ +export function formatLogTimestamp(absoluteMs: number): string { + function pad(p: string | number, c: number) { + return String(p).padStart(c, '0'); + } + const d = new Date(absoluteMs); + // new Date rounds down milliseconds; recover sub-millisecond precision separately. + // This will be imperfect because of float rounding errors but still better + // than not having them. + const ns = Math.trunc((absoluteMs - Math.trunc(absoluteMs)) * 10 ** 6); + return ( + `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1, 2)}-${pad(d.getUTCDate(), 2)} ` + + `${pad(d.getUTCHours(), 2)}:${pad(d.getUTCMinutes(), 2)}:${pad(d.getUTCSeconds(), 2)}.` + + `${pad(d.getUTCMilliseconds(), 3)}${pad(ns, 6)} UTC` + ); +} + +/** + * Format a Log marker payload into a MOZ_LOG canonical line. + * + * Returns null if the entry has no message content and should be skipped. + * + * Two payload formats are supported: + * - New format: { message, level } where `level` is a string table index + * resolving to "Error" / "Warning" / "Info" / "Debug" / "Verbose", and the + * module name is taken from the marker's `name` field (also a string table + * index, passed here as `moduleName`). + * - Legacy format: { name, module } where `module` may include a level prefix + * ("D/nsHttp") or just a bare module name ("nsHttp"). + * + * @param timestampStr - Pre-formatted timestamp from formatLogTimestamp() + * @param processName - Thread's process name (e.g. "GeckoMain") + * @param pid - Process ID + * @param threadName - Thread name (e.g. "Compositor") + * @param data - The LogMarkerPayload from the marker table + * @param moduleName - For the new format: module name resolved from the + * profile string table (markers.name). Ignored for the + * legacy format, which encodes the module in data.module. + * @param stringArray - Profile shared string table, used to resolve data.level + * in the new format. + */ +export function formatLogStatement( + timestampStr: string, + processName: string, + pid: number | string, + threadName: string, + data: LogMarkerPayload, + moduleName: string, + stringArray: string[] +): string | null { + if ('message' in data) { + if (!data.message) { + return null; + } + const levelStr = stringArray[data.level] ?? ''; + const levelLetter = LOG_LEVEL_STRING_TO_LETTER[levelStr] ?? 'D'; + return `${timestampStr} - [${processName} ${pid}: ${threadName}]: ${levelLetter}/${moduleName} ${data.message.trim()}`; + } + if (!data.name) { + return null; + } + const prefix = data.module.includes('/') ? '' : 'D/'; + return `${timestampStr} - [${processName} ${pid}: ${threadName}]: ${prefix}${data.module} ${data.name.trim()}`; +} diff --git a/src/profile-query/formatters/marker-info.ts b/src/profile-query/formatters/marker-info.ts index cb83a08072..c6ddaca913 100644 --- a/src/profile-query/formatters/marker-info.ts +++ b/src/profile-query/formatters/marker-info.ts @@ -43,9 +43,19 @@ import type { DurationStats, RateStats, MarkerFilterOptions, + ProfileLogsResult, } from '../types'; -import { isNetworkMarker } from 'firefox-profiler/profile-logic/marker-data'; -import type { NetworkPayload } from 'firefox-profiler/types/markers'; +import { + isNetworkMarker, + LOG_LEVEL_STRING_TO_LETTER, + LOG_LETTER_TO_LEVEL, + formatLogTimestamp, + formatLogStatement, +} from 'firefox-profiler/profile-logic/marker-data'; +import type { + NetworkPayload, + LogMarkerPayload, +} from 'firefox-profiler/types/markers'; /** * Aggregated statistics for a group of markers. @@ -1720,3 +1730,154 @@ export function collectThreadNetwork( requests, }; } + +export function collectProfileLogs( + store: Store, + threadMap: ThreadMap, + filterOptions: { + thread?: string; + module?: string; + level?: string; + search?: string; + limit?: number; + } = {} +): ProfileLogsResult { + const { module, level, search, limit } = filterOptions; + const state = store.getState(); + const profile = getProfile(state); + const profileStartTime = profile.meta.startTime; + const stringArray = profile.shared.stringArray; + + // Resolve which thread indexes to include. + const threadIndexes: Set | null = + filterOptions.thread !== undefined + ? new Set(threadMap.threadIndexesForHandle(filterOptions.thread)) + : null; + + // Map level filter string to the numeric threshold. + const LEVEL_NAMES: Record = { + error: 1, + warn: 2, + info: 3, + debug: 4, + verbose: 5, + }; + const maxLevel = + level !== undefined ? (LEVEL_NAMES[level.toLowerCase()] ?? 5) : 5; + + const lowerModule = module?.toLowerCase(); + const lowerSearch = search?.toLowerCase(); + + const entries: string[] = []; + + for ( + let threadIndex = 0; + threadIndex < profile.threads.length; + threadIndex++ + ) { + if (threadIndexes !== null && !threadIndexes.has(threadIndex)) { + continue; + } + const thread = profile.threads[threadIndex]; + const { markers } = thread; + const processName = thread.processName ?? 'Unknown Process'; + const pid = thread.pid; + const threadName = thread.name; + + for (let i = 0; i < markers.length; i++) { + const startTime = markers.startTime[i]; + if (startTime === null) { + continue; + } + + const data = markers.data[i]; + if (data?.type !== 'Log') { + continue; + } + + const logData = data as LogMarkerPayload; + let moduleName: string; + let message: string; + let levelLetter: string; + + if ('message' in logData) { + if (!logData.message) { + continue; + } + moduleName = stringArray[markers.name[i]] ?? ''; + const levelStr = stringArray[logData.level] ?? ''; + levelLetter = LOG_LEVEL_STRING_TO_LETTER[levelStr] ?? 'D'; + message = logData.message.trim(); + } else { + if (!logData.name) { + continue; + } + // Legacy format: data.module is either "D/nsHttp" or just "nsHttp". + const rawModule = logData.module; + const slashIdx = rawModule.indexOf('/'); + if (slashIdx !== -1) { + levelLetter = rawModule.slice(0, slashIdx); + moduleName = rawModule.slice(slashIdx + 1); + } else { + levelLetter = 'D'; + moduleName = rawModule; + } + message = logData.name.trim(); + } + + if ( + lowerModule !== undefined && + !moduleName.toLowerCase().includes(lowerModule) + ) { + continue; + } + + if ((LOG_LETTER_TO_LEVEL[levelLetter] ?? 5) > maxLevel) { + continue; + } + + if ( + lowerSearch !== undefined && + !message.toLowerCase().includes(lowerSearch) + ) { + continue; + } + + const timestampStr = formatLogTimestamp(profileStartTime + startTime); + const formatted = formatLogStatement( + timestampStr, + processName, + pid, + threadName, + logData, + moduleName, + stringArray + ); + if (formatted !== null) { + entries.push(formatted); + } + } + } + + // Lexicographic sort equals chronological order since the timestamp prefix + // is ISO-like ("YYYY-MM-DD HH:MM:SS..."), matching extractGeckoLogs behavior. + entries.sort(); + + const totalCount = entries.length; + const limitedEntries = + limit !== undefined ? entries.slice(0, limit) : entries; + + return { + type: 'profile-logs', + entries: limitedEntries, + totalCount, + filters: + filterOptions.thread !== undefined || + module !== undefined || + level !== undefined || + search !== undefined || + limit !== undefined + ? { thread: filterOptions.thread, module, level, search, limit } + : undefined, + }; +} diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 138bade77e..25b6490a7a 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -54,6 +54,7 @@ import { collectThreadNetwork, collectMarkerStack, collectMarkerInfo, + collectProfileLogs, } from './formatters/marker-info'; import { parseTimeValue } from './time-range-parser'; import { FilterStack, pushSpecTransforms } from './filter-stack'; @@ -94,6 +95,7 @@ import type { ThreadMarkersResult, ThreadNetworkResult, ThreadFunctionsResult, + ProfileLogsResult, MarkerFilterOptions, FunctionFilterOptions, SampleFilterSpec, @@ -979,6 +981,27 @@ export class ProfileQuerier { return { ...result, context: this._getContext() }; } + /** + * Extract Log-type markers from the profile in MOZ_LOG format. + * Iterates all threads by default; supports filtering by thread, module, level, search, and limit. + */ + async profileLogs( + filterOptions: { + thread?: string; + module?: string; + level?: string; + search?: string; + limit?: number; + } = {} + ): Promise> { + const result = collectProfileLogs( + this._store, + this._threadMap, + filterOptions + ); + return { ...result, context: this._getContext() }; + } + /** * List all functions for a thread with their CPU percentages. * Supports filtering by search string, minimum self time, and limit. diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 5433a57527..704c6d2d59 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -459,6 +459,19 @@ export type MarkerGroupData = { subGroups?: MarkerGroupData[]; }; +export type ProfileLogsResult = { + type: 'profile-logs'; + entries: string[]; + totalCount: number; + filters?: { + thread?: string; + module?: string; + level?: string; + search?: string; + limit?: number; + }; +}; + export type ThreadFunctionsResult = { type: 'thread-functions'; threadHandle: string; diff --git a/src/test/fixtures/profiles/marker-schema.ts b/src/test/fixtures/profiles/marker-schema.ts index 52257e9791..d5f2f61bfa 100644 --- a/src/test/fixtures/profiles/marker-schema.ts +++ b/src/test/fixtures/profiles/marker-schema.ts @@ -114,6 +114,8 @@ export const markerSchemaForTests: MarkerSchema[] = [ fields: [ { key: 'module', label: 'Module', format: 'string' }, { key: 'name', label: 'Name', format: 'string' }, + // New format: level is a string table index ("Error"/"Warning"/"Info"/"Debug"/"Verbose"). + { key: 'level', label: 'Level', format: 'unique-string' }, ], }, { diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index 9f9343610e..6702059ad9 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -278,6 +278,11 @@ Object { "key": "name", "label": "Name", }, + Object { + "format": "unique-string", + "key": "level", + "label": "Level", + }, ], "name": "Log", "tableLabel": "({marker.data.module}) {marker.data.name}", diff --git a/src/test/unit/window-console.test.ts b/src/test/unit/window-console.test.ts index 8dbd8a0a3f..975e53b985 100644 --- a/src/test/unit/window-console.test.ts +++ b/src/test/unit/window-console.test.ts @@ -120,7 +120,7 @@ describe('console-accessible values on the window object', function () { null, { type: 'Log', - level: 1, + level: 'Error', message: 'ParentChannelListener::ParentChannelListener [this=7fb5e19b98d0]', }, @@ -131,22 +131,27 @@ describe('console-accessible values on the window object', function () { null, { type: 'Log', - level: 2, + level: 'Warning', message: 'nsJARChannel::nsJARChannel [this=0x87f1ec80]\n', }, ], - ['cubeb', 200, null, { type: 'Log', level: 3, message: 'cubeb_init' }], + [ + 'cubeb', + 200, + null, + { type: 'Log', level: 'Info', message: 'cubeb_init' }, + ], [ 'AudioStream', 210, null, - { type: 'Log', level: 4, message: 'AudioStream init\n' }, + { type: 'Log', level: 'Debug', message: 'AudioStream init\n' }, ], [ 'VideoSink', 220, null, - { type: 'Log', level: 5, message: 'VideoSink::VideoSink' }, + { type: 'Log', level: 'Verbose', message: 'VideoSink::VideoSink' }, ], ]); const store = storeWithProfile(profile); diff --git a/src/types/markers.ts b/src/types/markers.ts index 8f5f5e5005..054559d0cf 100644 --- a/src/types/markers.ts +++ b/src/types/markers.ts @@ -633,6 +633,14 @@ export type ChromeEventPayload = { /** * Gecko includes rich log information. This marker payload is used to mirror that * log information in the profile. + * + * Two formats are in use: + * - Legacy: { name, module } where module may be "D/nsHttp" (level prefix + * included) or just "nsHttp" (bare module name, implicitly Debug level). + * - New: { level, message } where `level` is a string table index resolving + * to "Error" / "Warning" / "Info" / "Debug" / "Verbose", the module name is + * taken from the marker's own name field, and an optional `color` hint may + * be present. */ export type LogMarkerPayload = | { @@ -642,8 +650,10 @@ export type LogMarkerPayload = } | { type: 'Log'; + // String table index resolving to "Error", "Warning", "Info", "Debug", or "Verbose". level: number; message: string; + color?: string; }; export type DOMEventMarkerPayload = { diff --git a/src/utils/window-console.ts b/src/utils/window-console.ts index 7d792200fb..e88a025c4f 100644 --- a/src/utils/window-console.ts +++ b/src/utils/window-console.ts @@ -24,6 +24,10 @@ import { } from 'firefox-profiler/utils/dark-mode'; import { printSliceTree } from 'firefox-profiler/utils/slice-tree'; import type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; +import { + formatLogTimestamp, + formatLogStatement, +} from 'firefox-profiler/profile-logic/marker-data'; // Despite providing a good libdef for Object.defineProperty, Flow still // special-cases the `value` property: if it's missing it throws an error. Using @@ -273,24 +277,8 @@ export function addDataToWindowObject( }; // This function extracts MOZ_LOGs saved as markers in a Firefox profile, - // using the MOZ_LOG canonical format. All logs are saved as a debug log - // because the log level information isn't saved in these markers. + // using the MOZ_LOG canonical format. target.extractGeckoLogs = function () { - function pad(p: string | number, c: number) { - return String(p).padStart(c, '0'); - } - - // This transforms a timestamp to a string as output by mozlog usually. - function d2s(ts: number) { - const d = new Date(ts); - // new Date rounds down the timestamp (in milliseconds) to the lower integer, - // let's get the microseconds and nanoseconds differently. - // This will be imperfect because of float rounding errors but still better - // than not having them. - const ns = Math.trunc((ts - Math.trunc(ts)) * 10 ** 6); - return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1, 2)}-${pad(d.getUTCDate(), 2)} ${pad(d.getUTCHours(), 2)}:${pad(d.getUTCMinutes(), 2)}:${pad(d.getUTCSeconds(), 2)}.${pad(d.getUTCMilliseconds(), 3)}${pad(ns, 6)} UTC`; - } - const logs = []; // This algorithm loops over the raw marker table instead of using the @@ -300,14 +288,6 @@ export function addDataToWindowObject( const range = selectorsForConsole.profile.getPreviewSelectionRange(getState()); - const LOG_LEVEL_LETTER: Record = { - 1: 'E', - 2: 'W', - 3: 'I', - 4: 'D', - 5: 'V', - }; - for (const thread of profile.threads) { const { markers } = thread; @@ -320,25 +300,26 @@ export function addDataToWindowObject( startTime <= range.end ) { const data = markers.data[i] as LogMarkerPayload; - const strTimestamp = d2s(profile.meta.startTime + startTime); + const absoluteTs = profile.meta.startTime + startTime; + const strTimestamp = formatLogTimestamp(absoluteTs); const processName = thread.processName ?? 'Unknown Process'; - - let statement; - if ('message' in data) { - if (!data.message) { - continue; - } - const moduleName = profile.shared.stringArray[markers.name[i]]; - const levelLetter = LOG_LEVEL_LETTER[data.level] ?? 'D'; - statement = `${strTimestamp} - [${processName} ${thread.pid}: ${thread.name}]: ${levelLetter}/${moduleName} ${data.message.trim()}`; - } else { - if (!data.name) { - continue; - } - const prefix = data.module.includes('/') ? '' : 'D/'; - statement = `${strTimestamp} - [${processName} ${thread.pid}: ${thread.name}]: ${prefix}${data.module} ${data.name.trim()}`; + const stringArray = profile.shared.stringArray; + // For the new format the module name lives in the marker's name field. + // For the legacy format it is embedded in data.module; formatLogStatement + // handles that internally, so this value is not used in that case. + const moduleName = stringArray[markers.name[i]]; + const statement = formatLogStatement( + strTimestamp, + processName, + thread.pid, + thread.name, + data, + moduleName, + stringArray + ); + if (statement !== null) { + logs.push(statement); } - logs.push(statement); } } } From f203e3527323b6f63557f06a08293d4e978c157e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 15 Apr 2026 17:09:14 +0200 Subject: [PATCH 52/63] Convert profiler-cli version to alpha --- profiler-cli/README.md | 6 +++++- profiler-cli/package.json | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/profiler-cli/README.md b/profiler-cli/README.md index a678915718..e8de48a4f6 100644 --- a/profiler-cli/README.md +++ b/profiler-cli/README.md @@ -2,10 +2,12 @@ A command-line interface for querying Firefox Profiler profiles with persistent daemon sessions. +> **Alpha release** — this package is in early development. Commands and options may change between versions. + ## Installation ```bash -npm install -g @firefox-profiler/profiler-cli +npm install -g @firefox-devtools/profiler-cli@alpha ``` Requires Node.js >= 24. @@ -21,6 +23,8 @@ profiler-cli thread samples # Show hot functions profiler-cli stop # Stop the daemon ``` +`profiler-cli` is also available as `pq` for shorter invocations (e.g. `pq thread samples`). + Run `profiler-cli guide` for a detailed usage guide with patterns and tips. Run `profiler-cli --help` for the full options reference. diff --git a/profiler-cli/package.json b/profiler-cli/package.json index 1ca31cd8fd..baa1a0a439 100644 --- a/profiler-cli/package.json +++ b/profiler-cli/package.json @@ -1,6 +1,6 @@ { - "name": "@firefox-profiler/profiler-cli", - "version": "0.1.0", + "name": "@firefox-devtools/profiler-cli", + "version": "0.1.0-alpha.0", "description": "Command-line interface for querying Firefox Profiler profiles with persistent daemon sessions", "scripts": { "prepublishOnly": "test -f dist/profiler-cli.js || (echo 'Run yarn build-profiler-cli from the repo root first' && exit 1)" From 69dee51b3157ce64c53113a0d0514d6fc583de51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 15 Apr 2026 17:25:03 +0200 Subject: [PATCH 53/63] Update the cli readme --- profiler-cli/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/profiler-cli/README.md b/profiler-cli/README.md index e8de48a4f6..cc37eccc71 100644 --- a/profiler-cli/README.md +++ b/profiler-cli/README.md @@ -1,4 +1,4 @@ -# Profiler CLI +# Firefox Profiler CLI A command-line interface for querying Firefox Profiler profiles with persistent daemon sessions. @@ -25,7 +25,7 @@ profiler-cli stop # Stop the daemon `profiler-cli` is also available as `pq` for shorter invocations (e.g. `pq thread samples`). -Run `profiler-cli guide` for a detailed usage guide with patterns and tips. +Run `profiler-cli guide` for a detailed usage guide with patterns and tips. Run `profiler-cli --help` for the full options reference. ## Commands From 53c71d57e93a6c3bc83dd608ee2a7da4df2eaa4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 15 Apr 2026 18:06:44 +0200 Subject: [PATCH 54/63] Improve the unknown command handling --- profiler-cli/src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/profiler-cli/src/index.ts b/profiler-cli/src/index.ts index 84498d3e8b..09476ea3c6 100644 --- a/profiler-cli/src/index.ts +++ b/profiler-cli/src/index.ts @@ -86,9 +86,9 @@ Examples: profiler-cli stop --all` ); - // Bare `profiler-cli` with no arguments - program.action(() => { - console.error('Error: No command specified\n'); + // Unknown commands + program.on('command:*', (operands: string[]) => { + console.error(`Error: Unknown command '${operands[0]}'\n`); program.outputHelp(); process.exit(1); }); From 7748cb1dccc6547496a8efcdc353d29a1ddbf339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 15 Apr 2026 23:49:59 +0200 Subject: [PATCH 55/63] Add `profiler-cli thread page-load` for page load analysis This is very similar to Denis' tool's --page-load --- profiler-cli/README.md | 3 + profiler-cli/guide.txt | 10 + profiler-cli/src/commands/thread.ts | 54 +++ profiler-cli/src/daemon.ts | 5 + profiler-cli/src/formatters.ts | 198 +++++++++ profiler-cli/src/output.ts | 3 + profiler-cli/src/protocol.ts | 17 +- src/profile-query/formatters/page-load.ts | 502 ++++++++++++++++++++++ src/profile-query/index.ts | 20 + src/profile-query/types.ts | 60 +++ 10 files changed, 870 insertions(+), 2 deletions(-) create mode 100644 src/profile-query/formatters/page-load.ts diff --git a/profiler-cli/README.md b/profiler-cli/README.md index cc37eccc71..d64b657627 100644 --- a/profiler-cli/README.md +++ b/profiler-cli/README.md @@ -41,6 +41,7 @@ profiler-cli thread samples-top-down # Show top-down call tree (where CPU profiler-cli thread samples-bottom-up # Show bottom-up call tree (what calls hot functions) profiler-cli thread markers # List markers with aggregated statistics profiler-cli thread functions # List all functions with CPU percentages +profiler-cli thread page-load # Show page load summary (navigation timing, resources, CPU, jank) profiler-cli marker info # Show detailed marker information (e.g., m-1234) profiler-cli marker stack # Show full stack trace for a marker profiler-cli function expand # Show full untruncated function name (e.g., f-123) @@ -87,6 +88,8 @@ profiler-cli thread info --thread t-0 # View info for specific thread witho | `--limit ` | Limit number of results shown | | `--max-lines ` | Limit call tree nodes for `samples-top-down`/`samples-bottom-up` (default: 100) | | `--scoring ` | Call tree scoring: `exponential-0.95`, `exponential-0.9` (default), `exponential-0.8`, `harmonic-0.1`, `harmonic-0.5`, `harmonic-1.0`, `percentage-only` | +| `--navigation ` | Select which navigation to show in `thread page-load` (1-based, default: last completed) | +| `--jank-limit ` | Max jank periods to show in `thread page-load` (default: 10, 0 = show all) | | `--all` | Show all threads in `profile info` (overrides default top-5 limit) | | `--session ` | Use a specific session instead of the current one | diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index 700c1429e4..a1feff4dbd 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -268,6 +268,16 @@ COMMON ANALYSIS PATTERNS profiler-cli thread markers --auto-group profiler-cli thread markers --group-by type,name + Investigate a page load: + profiler-cli thread page-load Overview: milestones, top resources, CPU categories, jank + profiler-cli thread page-load --navigation 2 If multiple navigations, inspect each one separately (1-based) + profiler-cli thread page-load --jank-limit 0 Show all jank periods (default: first 10) + profiler-cli zoom push m- Zoom into a specific jank period (handle from page-load output) + profiler-cli thread samples What was executing during the jank? + profiler-cli zoom pop + Requires page load markers in the selected thread (typically GeckoMain or a content process thread). + Marker handles shown in page-load output can be passed to "marker info" or "zoom push". + Analyze network activity: profiler-cli thread network All requests with timing phases profiler-cli thread network --min-duration 200 Slow requests only (>= 200ms) diff --git a/profiler-cli/src/commands/thread.ts b/profiler-cli/src/commands/thread.ts index bf51a6fc85..c9d8ba22a5 100644 --- a/profiler-cli/src/commands/thread.ts +++ b/profiler-cli/src/commands/thread.ts @@ -387,6 +387,60 @@ export function registerThreadCommand( console.log(formatOutput(result, opts.json ?? false)); }); + // thread page-load + addGlobalOptions( + thread + .command('page-load') + .description( + 'Show page load summary: navigation timing, resources, CPU categories, and jank' + ) + .option('--thread ', 'Thread handle (e.g. t-0)') + .option( + '--navigation ', + 'Select which navigation to show (1-based, default: last completed)' + ) + .option( + '--jank-limit ', + 'Max jank periods to show (default: 10, 0 = show all)' + ) + ).action(async (opts) => { + const pageLoadOptions: { navigationIndex?: number; jankLimit?: number } = + {}; + + if (opts.navigation !== undefined) { + const v = parseInt(opts.navigation, 10); + if (isNaN(v) || v < 1) { + console.error( + 'Error: --navigation must be a positive integer (1-based index)' + ); + process.exit(1); + } + pageLoadOptions.navigationIndex = v; + } + if (opts.jankLimit !== undefined) { + const v = parseInt(opts.jankLimit, 10); + if (isNaN(v) || v < 0) { + console.error( + 'Error: --jank-limit must be a non-negative integer (0 = show all)' + ); + process.exit(1); + } + pageLoadOptions.jankLimit = v === 0 ? undefined : v; + } + + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'page-load', + thread: opts.thread, + pageLoadOptions, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + // thread functions addSampleFilterOptions( addGlobalOptions( diff --git a/profiler-cli/src/daemon.ts b/profiler-cli/src/daemon.ts index 5d10809ef3..70dd05cf64 100644 --- a/profiler-cli/src/daemon.ts +++ b/profiler-cli/src/daemon.ts @@ -338,6 +338,11 @@ export class Daemon { command.thread, command.networkFilters ); + case 'page-load': + return this.querier!.threadPageLoad( + command.thread, + command.pageLoadOptions + ); default: throw assertExhaustiveCheck(command); } diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 4039f2dc4f..04f3459eef 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -26,6 +26,7 @@ import type { ThreadMarkersResult, ThreadFunctionsResult, ThreadNetworkResult, + ThreadPageLoadResult, NetworkPhaseTimings, MarkerGroupData, CallTreeNode, @@ -1300,3 +1301,200 @@ export function formatProfileLogsResult( return lines.join('\n'); } + +export function formatThreadPageLoadResult( + result: WithContext +): string { + const lines: string[] = [formatContextHeader(result.context), '']; + + if (result.navigationTotal === 0) { + lines.push( + 'No page load markers found in this thread.', + 'Try a different thread or check that the profile includes a web page load.' + ); + return lines.join('\n'); + } + + const navLabel = + result.navigationTotal > 1 + ? ` [Navigation ${result.navigationIndex} of ${result.navigationTotal}]` + : ''; + + lines.push( + `Page Load Summary — ${result.friendlyThreadName} (${result.threadHandle})${navLabel}` + ); + lines.push(''); + + if (result.url) { + lines.push(` URL: ${result.url}`); + lines.push(''); + } + + // ── Navigation Timing ────────────────────────────────────────────────────── + + lines.push('──── Navigation Timing ────'); + lines.push(''); + + const milestones = result.milestones; + + if (milestones.length === 0) { + lines.push(' No navigation timing data available.'); + } else { + const TIMELINE_WIDTH = 60; + // Axis max = largest non-TTFI milestone. TTFI is shown with ▶ if it + // exceeds this, since it's post-load and can dwarf everything else. + const nonTtfiMilestones = milestones.filter((m) => m.name !== 'TTFI'); + const axisMax = + nonTtfiMilestones.length > 0 + ? Math.max(...nonTtfiMilestones.map((m) => m.timeMs)) + : milestones[milestones.length - 1].timeMs; + + // Label column: name (right-aligned) + space + handle (left-aligned) + const maxLabelLen = Math.max(...milestones.map((m) => m.name.length)); + const labelWidth = Math.max(maxLabelLen, 3); + const maxHandleLen = Math.max( + ...milestones.map((m) => m.markerHandle.length) + ); + // Total prefix width before the bar: labelWidth + 1 (space) + maxHandleLen + 2 (gap) + const prefixWidth = labelWidth + 1 + maxHandleLen + 2; + + // Time header line + const startLabel = '0ms'; + const endLabel = `${Math.round(axisMax)}ms`; + const padding = TIMELINE_WIDTH - startLabel.length - endLabel.length; + lines.push( + ` ${' '.repeat(prefixWidth)}${startLabel}${' '.repeat(Math.max(0, padding))}${endLabel}` + ); + + // Axis line + lines.push(` ${' '.repeat(prefixWidth)}${'─'.repeat(TIMELINE_WIDTH)}`); + + // One row per milestone + for (const m of milestones) { + const label = m.name.padStart(labelWidth); + const handle = m.markerHandle.padEnd(maxHandleLen); + let bar: string; + if (m.timeMs > axisMax) { + bar = '─'.repeat(TIMELINE_WIDTH) + '▶'; + } else { + const pos = + axisMax > 0 + ? Math.round((m.timeMs / axisMax) * TIMELINE_WIDTH) + : TIMELINE_WIDTH; + // Clamp to TIMELINE_WIDTH - 1 so │ always fits within the axis width + const drawPos = Math.min(pos, TIMELINE_WIDTH - 1); + bar = '─'.repeat(Math.max(0, drawPos)) + '│'; + } + lines.push(` ${label} ${handle} ${bar} ${Math.round(m.timeMs)}ms`); + } + } + + lines.push(''); + + // ── Resources ───────────────────────────────────────────────────────────── + + lines.push(`──── Resources (${result.resourceCount} requests) ────`); + lines.push(''); + + if (result.resourceCount === 0) { + lines.push(' No network requests recorded during page load.'); + } else { + if (result.resourceAvgMs !== null) { + lines.push(` Avg duration: ${formatDuration(result.resourceAvgMs)}`); + } + if (result.resourceMaxMs !== null) { + lines.push(` Max duration: ${formatDuration(result.resourceMaxMs)}`); + } + lines.push(''); + + if (result.resourcesByType.length > 0) { + lines.push(' By type:'); + for (const t of result.resourcesByType) { + const countStr = String(t.count).padStart(4); + const pctStr = t.percentage.toFixed(1).padStart(5); + lines.push(` ${t.type.padEnd(8)} ${countStr} (${pctStr}%)`); + } + lines.push(''); + } + + if (result.topResources.length > 0) { + lines.push(' Top 10 longest:'); + result.topResources.forEach((r, idx) => { + const num = String(idx + 1).padStart(3); + const dur = formatDuration(r.durationMs).padStart(7); + const file = r.filename.padEnd(50); + lines.push( + ` ${num}. ${dur} ${file} ${r.resourceType} ${r.markerHandle}` + ); + }); + } + } + + lines.push(''); + + // ── CPU Categories ───────────────────────────────────────────────────────── + + lines.push(`──── CPU Categories (${result.totalSamples} samples) ────`); + lines.push(''); + + if (result.categories.length === 0) { + lines.push(' No sample data available during page load.'); + } else { + const BAR_WIDTH = 28; + const maxCount = result.categories[0].count; + const maxNameLen = Math.max(...result.categories.map((c) => c.name.length)); + + for (const cat of result.categories) { + const barLen = + maxCount > 0 ? Math.round((cat.count / maxCount) * BAR_WIDTH) : 0; + const bar = '█'.repeat(barLen).padEnd(BAR_WIDTH); + const name = cat.name.padEnd(maxNameLen); + const countStr = String(cat.count).padStart(6); + const pctStr = cat.percentage.toFixed(1).padStart(5); + lines.push(` ${name} ${bar} ${countStr} ${pctStr}%`); + } + } + + lines.push(''); + + // ── Jank ────────────────────────────────────────────────────────────────── + + lines.push(`──── Jank (${result.jankTotal} periods) ────`); + lines.push(''); + + if (result.jankTotal === 0) { + lines.push(' No jank detected during page load.'); + } else { + const shown = result.jankPeriods.length; + result.jankPeriods.forEach((jank, idx) => { + lines.push( + ` Jank ${idx + 1} (${jank.markerHandle}) at ${Math.round(jank.startMs)}ms ${Math.round(jank.durationMs)}ms duration [${jank.startHandle} → ${jank.endHandle}]` + ); + + if (jank.topFunctions.length > 0) { + lines.push(' Top functions:'); + for (const fn of jank.topFunctions) { + const name = truncateFunctionName(fn.name, 60); + lines.push(` ${name.padEnd(60)} ${fn.sampleCount} samples`); + } + } + + if (jank.categories.length > 0) { + const catStr = jank.categories + .map((c) => `${c.name}: ${c.count}`) + .join(' '); + lines.push(` Categories: ${catStr}`); + } + + lines.push(''); + }); + + if (shown < result.jankTotal) { + lines.push( + ` Showing ${shown} of ${result.jankTotal} jank periods. Use --jank-limit or --jank-limit 0 to show more.` + ); + } + } + + return lines.join('\n'); +} diff --git a/profiler-cli/src/output.ts b/profiler-cli/src/output.ts index 2a726db4a0..186ac2c8eb 100644 --- a/profiler-cli/src/output.ts +++ b/profiler-cli/src/output.ts @@ -26,6 +26,7 @@ import { formatThreadFunctionsResult, formatThreadNetworkResult, formatProfileLogsResult, + formatThreadPageLoadResult, } from './formatters'; /** @@ -82,6 +83,8 @@ export function formatOutput( return formatThreadNetworkResult(result); case 'profile-logs': return formatProfileLogsResult(result); + case 'thread-page-load': + return formatThreadPageLoadResult(result); default: throw assertExhaustiveCheck(result); } diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts index 725746fd3f..94d0287f82 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -33,6 +33,12 @@ export type { NetworkRequestEntry, NetworkPhaseTimings, ThreadFunctionsResult, + ThreadPageLoadResult, + NavigationMilestone, + PageLoadResourceEntry, + PageLoadCategoryEntry, + JankPeriod, + JankFunction, DurationStats, RateStats, MarkerGroupData, @@ -66,6 +72,7 @@ import type { ThreadMarkersResult, ThreadNetworkResult, ThreadFunctionsResult, + ThreadPageLoadResult, FilterStackResult, ProfileLogsResult, } from '../../src/profile-query/types'; @@ -104,7 +111,8 @@ export type ClientCommand = | 'samples-bottom-up' | 'markers' | 'functions' - | 'network'; + | 'network' + | 'page-load'; thread?: string; includeIdle?: boolean; search?: string; @@ -117,6 +125,10 @@ export type ClientCommand = maxDuration?: number; limit?: number; }; + pageLoadOptions?: { + navigationIndex?: number; + jankLimit?: number; + }; /** Ephemeral sample filters applied only to this command invocation */ sampleFilters?: SampleFilterSpec[]; } @@ -176,7 +188,8 @@ export type CommandResult = | WithContext | WithContext | WithContext - | WithContext; + | WithContext + | WithContext; export interface SessionMetadata { id: string; diff --git a/src/profile-query/formatters/page-load.ts b/src/profile-query/formatters/page-load.ts new file mode 100644 index 0000000000..115df097d5 --- /dev/null +++ b/src/profile-query/formatters/page-load.ts @@ -0,0 +1,502 @@ +/* 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/. */ + +import { getSelectedThreadIndexes } from 'firefox-profiler/selectors/url-state'; +import { getCategories } from 'firefox-profiler/selectors/profile'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { isNetworkMarker } from 'firefox-profiler/profile-logic/marker-data'; +import type { Store } from '../../types/store'; +import type { ThreadMap } from '../thread-map'; +import type { TimestampManager } from '../timestamps'; +import type { MarkerMap } from '../marker-map'; +import type { + ThreadPageLoadResult, + PageLoadResourceEntry, + PageLoadCategoryEntry, + JankPeriod, + JankFunction, +} from '../types'; +import type { NetworkPayload } from 'firefox-profiler/types/markers'; +import type { Thread, CategoryList } from 'firefox-profiler/types'; + +// ===== Navigation group helpers ===== + +// Internal milestone type that tracks the source marker index for handle assignment. +type NavGroupMilestone = { + name: string; + timeMs: number; + markerIndex: number; +}; + +type NavGroup = { + innerWindowID: number; + navStart: number; + loadEnd: number | null; + url: string | null; + milestones: NavGroupMilestone[]; +}; + +function getInnerWindowID(data: unknown): number | undefined { + if (data !== null && typeof data === 'object' && 'innerWindowID' in data) { + const id = (data as { innerWindowID?: unknown }).innerWindowID; + if (typeof id === 'number') { + return id; + } + } + return undefined; +} + +// Marker name → milestone label for the common single-condition markers +const MILESTONE_MARKER_NAMES: Record = { + FirstContentfulPaint: 'FCP', + FirstContentfulComposite: 'FCC', + LargestContentfulPaint: 'LCP', + 'TimeToFirstInteractive (TTFI)': 'TTFI', +}; + +function getOrCreateNavGroup( + navGroups: Map, + innerWindowID: number, + navStart: number +): NavGroup { + let group = navGroups.get(innerWindowID); + if (!group) { + group = { + innerWindowID, + navStart, + loadEnd: null, + url: null, + milestones: [], + }; + navGroups.set(innerWindowID, group); + } + return group; +} + +function addMilestone( + group: NavGroup, + name: string, + markerEnd: number, + markerIndex: number +): void { + if (!group.milestones.some((m) => m.name === name)) { + group.milestones.push({ + name, + timeMs: markerEnd - group.navStart, + markerIndex, + }); + } +} + +function classifyContentType(contentType: string | null | undefined): string { + if (!contentType) { + return 'Other'; + } + const ct = contentType.toLowerCase().split(';')[0].trim(); + if (ct.includes('javascript') || ct.includes('ecmascript')) { + return 'JS'; + } + if (ct === 'text/css') { + return 'CSS'; + } + if (ct.startsWith('image/')) { + return 'Image'; + } + if (ct === 'text/html' || ct === 'application/xhtml+xml') { + return 'HTML'; + } + if (ct === 'application/json' || ct === 'text/json') { + return 'JSON'; + } + if ( + ct.startsWith('font/') || + ct.startsWith('application/font') || + ct === 'application/x-font-woff' + ) { + return 'Font'; + } + if (ct === 'application/wasm') { + return 'Wasm'; + } + return 'Other'; +} + +function filenameFromUrl(url: string): string { + let pathname = url; + try { + pathname = new URL(url).pathname; + } catch { + // Use raw url as fallback + } + const parts = pathname.split('/'); + const last = parts[parts.length - 1] || url; + return last.length > 50 ? last.slice(0, 47) + '...' : last; +} + +// ===== Leaf function name for a sample ===== + +function getLeafFunctionName( + sampleIndex: number, + thread: Thread +): string | null { + const stackIndex = thread.samples.stack[sampleIndex]; + if (stackIndex === null || stackIndex === undefined) { + return null; + } + const frameIndex = thread.stackTable.frame[stackIndex]; + const funcIndex = thread.frameTable.func[frameIndex]; + const nameIndex = thread.funcTable.name[funcIndex]; + return thread.stringTable.getString(nameIndex); +} + +// ===== Category counting helpers ===== + +function countCategoriesInRange( + thread: Thread, + categories: CategoryList, + startTime: number, + endTime: number +): PageLoadCategoryEntry[] { + const counts = new Map(); + let total = 0; + + for (let i = 0; i < thread.samples.length; i++) { + const t = thread.samples.time[i]; + if (t < startTime || t > endTime) { + continue; + } + const catIndex = thread.samples.category[i]; + const catName = + catIndex < categories.length ? categories[catIndex].name : 'Other'; + counts.set(catName, (counts.get(catName) ?? 0) + 1); + total++; + } + + if (total === 0) { + return []; + } + + return Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([name, count]) => ({ + name, + count, + percentage: (count / total) * 100, + })); +} + +// ===== Main collector ===== + +export function collectThreadPageLoad( + store: Store, + threadMap: ThreadMap, + timestampManager: TimestampManager, + markerMap: MarkerMap, + threadHandle?: string, + options: { navigationIndex?: number; jankLimit?: number } = {} +): ThreadPageLoadResult { + const { jankLimit = 10 } = options; + + const state = store.getState(); + const threadIndexes = + threadHandle !== undefined + ? threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(state); + + const displayThreadHandle = + threadHandle ?? threadMap.handleForThreadIndexes(threadIndexes); + + const threadSelectors = getThreadSelectors(threadIndexes); + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const fullMarkerList = threadSelectors.getFullMarkerList(state); + const allMarkerIndexes = threadSelectors.getFullMarkerListIndexes(state); + const categories = getCategories(state); + + // Use the unfiltered thread (all samples, no transforms) for sample-level access. + const rawThread: Thread = threadSelectors.getThread(state); + + // ===== Step 1: Build navigation groups from markers ===== + + const navGroups = new Map(); + + for (const i of allMarkerIndexes) { + const marker = fullMarkerList[i]; + const { name, data } = marker; + + if (marker.end === null) { + continue; + } + + if (name === 'DocumentLoad') { + const innerWindowID = getInnerWindowID(data); + if (innerWindowID === undefined) { + continue; + } + const group = getOrCreateNavGroup(navGroups, innerWindowID, marker.start); + group.loadEnd = marker.end; + addMilestone(group, 'Load', marker.end, i); + // Extract URL from payload text: "Document URL loaded after Xms..." + if (data !== null && typeof data === 'object' && 'name' in data) { + const textName = (data as { name?: unknown }).name; + if (typeof textName === 'string') { + const match = textName.match(/^Document (.+) loaded after/); + if (match) { + group.url = match[1]; + } + } + } + } else if ( + name === 'DOMContentLoaded' && + data !== null && + typeof data === 'object' && + 'category' in data && + (data as { category?: unknown }).category === 'Navigation' + ) { + const innerWindowID = getInnerWindowID(data); + if (innerWindowID === undefined) { + continue; + } + const group = getOrCreateNavGroup(navGroups, innerWindowID, marker.start); + addMilestone(group, 'DCL', marker.end, i); + } else { + const milestoneName = MILESTONE_MARKER_NAMES[name]; + if (milestoneName === undefined) { + continue; + } + const innerWindowID = getInnerWindowID(data); + if (innerWindowID === undefined) { + continue; + } + const group = getOrCreateNavGroup(navGroups, innerWindowID, marker.start); + addMilestone(group, milestoneName, marker.end, i); + } + } + + const realGroups: NavGroup[] = Array.from(navGroups.values()); + + realGroups.sort((a, b) => a.navStart - b.navStart); + + // Filter to groups that have at least a DocumentLoad (loadEnd != null) + const completeGroups = realGroups.filter((g) => g.loadEnd !== null); + + if (completeGroups.length === 0) { + return { + type: 'thread-page-load', + threadHandle: displayThreadHandle, + friendlyThreadName, + url: null, + navigationIndex: 0, + navigationTotal: 0, + navStartMs: 0, + milestones: [], + resourceCount: 0, + resourceAvgMs: null, + resourceMaxMs: null, + resourcesByType: [], + topResources: [], + totalSamples: 0, + categories: [], + jankTotal: 0, + jankPeriods: [], + }; + } + + // Select the requested navigation (1-based; default = last) + const navTotal = completeGroups.length; + const requestedIndex = options.navigationIndex ?? navTotal; + const clampedIndex = Math.max(1, Math.min(requestedIndex, navTotal)); + const nav = completeGroups[clampedIndex - 1]; + + const navStart = nav.navStart; + const loadEnd = nav.loadEnd!; + + // Add TTFB milestone from the main document's network marker + if (nav.url) { + for (const i of allMarkerIndexes) { + const m = fullMarkerList[i]; + if (!isNetworkMarker(m)) { + continue; + } + const d = m.data as NetworkPayload; + if ( + d.status === 'STATUS_STOP' && + d.URI === nav.url && + d.requestStart !== undefined && + d.responseStart !== undefined + ) { + nav.milestones.push({ + name: 'TTFB', + timeMs: d.responseStart - navStart, + markerIndex: i, + }); + break; + } + } + } + + // Sort milestones by timeMs + nav.milestones.sort((a, b) => a.timeMs - b.timeMs); + + // Data window ends at the largest non-TTFI milestone. TTFI reflects + // post-load JS work and would inflate the analysis sections. + const nonTtfiMs = nav.milestones + .filter((m) => m.name !== 'TTFI') + .map((m) => m.timeMs); + const dataWindowEndMs = + nonTtfiMs.length > 0 ? Math.max(...nonTtfiMs) : loadEnd - navStart; + const pageLoadEnd = navStart + dataWindowEndMs; + + // ===== Steps 2 & 4: Resources and Jank markers (single pass) ===== + + const resources: PageLoadResourceEntry[] = []; + const jankMarkerIndexes: number[] = []; + + for (const i of allMarkerIndexes) { + const m = fullMarkerList[i]; + + if (isNetworkMarker(m)) { + const d = m.data as NetworkPayload; + if ( + d.status === 'STATUS_STOP' && + d.startTime >= navStart && + d.startTime <= pageLoadEnd + ) { + resources.push({ + filename: filenameFromUrl(d.URI), + url: d.URI, + durationMs: d.endTime - d.startTime, + resourceType: classifyContentType(d.contentType), + markerHandle: markerMap.handleForMarker(threadIndexes, i), + }); + } + } else if ( + m.name === 'Jank' && + m.start >= navStart && + (m.end ?? m.start) <= pageLoadEnd + ) { + jankMarkerIndexes.push(i); + } + } + + resources.sort((a, b) => b.durationMs - a.durationMs); + + const resourceCount = resources.length; + let resourceAvgMs: number | null = null; + let resourceMaxMs: number | null = null; + + if (resourceCount > 0) { + const total = resources.reduce((sum, r) => sum + r.durationMs, 0); + resourceAvgMs = total / resourceCount; + resourceMaxMs = resources[0].durationMs; + } + + // Count by type + const typeCounts = new Map(); + for (const r of resources) { + typeCounts.set(r.resourceType, (typeCounts.get(r.resourceType) ?? 0) + 1); + } + const resourcesByType = Array.from(typeCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([type, count]) => ({ + type, + count, + percentage: (count / resourceCount) * 100, + })); + + const topResources = resources.slice(0, 10); + + // ===== Step 3: CPU Categories ===== + + const cpuCategories = countCategoriesInRange( + rawThread, + categories, + navStart, + pageLoadEnd + ); + + const totalSamples = cpuCategories.reduce((s, c) => s + c.count, 0); + + // ===== Step 5: Jank periods ===== + + const jankTotal = jankMarkerIndexes.length; + const limitedJankIndexes = jankMarkerIndexes.slice(0, jankLimit); + + const jankPeriods: JankPeriod[] = limitedJankIndexes.map((i) => { + const m = fullMarkerList[i]; + const jStart = m.start; + const jEnd = m.end ?? m.start; + + // Single pass to collect both categories and leaf functions + const categoryCounts = new Map(); + const funcCounts = new Map(); + let categoryTotal = 0; + + for (let s = 0; s < rawThread.samples.length; s++) { + const t = rawThread.samples.time[s]; + if (t < jStart || t > jEnd) { + continue; + } + const catIndex = rawThread.samples.category[s]; + const catName = + catIndex < categories.length ? categories[catIndex].name : 'Other'; + categoryCounts.set(catName, (categoryCounts.get(catName) ?? 0) + 1); + categoryTotal++; + + const name = getLeafFunctionName(s, rawThread); + if (name !== null) { + funcCounts.set(name, (funcCounts.get(name) ?? 0) + 1); + } + } + + const jankCategoryEntries: PageLoadCategoryEntry[] = + categoryTotal === 0 + ? [] + : Array.from(categoryCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([name, count]) => ({ + name, + count, + percentage: (count / categoryTotal) * 100, + })); + + const topFunctions: JankFunction[] = Array.from(funcCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([name, sampleCount]) => ({ name, sampleCount })); + + return { + startMs: jStart - navStart, + durationMs: jEnd - jStart, + markerHandle: markerMap.handleForMarker(threadIndexes, i), + startHandle: timestampManager.nameForTimestamp(jStart), + endHandle: timestampManager.nameForTimestamp(jEnd), + topFunctions, + categories: jankCategoryEntries, + }; + }); + + return { + type: 'thread-page-load', + threadHandle: displayThreadHandle, + friendlyThreadName, + url: nav.url, + navigationIndex: clampedIndex, + navigationTotal: navTotal, + navStartMs: navStart, + milestones: nav.milestones.map((m) => ({ + name: m.name, + timeMs: m.timeMs, + markerHandle: markerMap.handleForMarker(threadIndexes, m.markerIndex), + })), + resourceCount, + resourceAvgMs, + resourceMaxMs, + resourcesByType, + topResources, + totalSamples, + categories: cpuCategories, + jankTotal, + jankPeriods, + }; +} diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 25b6490a7a..55ea972ae2 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -56,6 +56,7 @@ import { collectMarkerInfo, collectProfileLogs, } from './formatters/marker-info'; +import { collectThreadPageLoad } from './formatters/page-load'; import { parseTimeValue } from './time-range-parser'; import { FilterStack, pushSpecTransforms } from './filter-stack'; import { @@ -95,6 +96,7 @@ import type { ThreadMarkersResult, ThreadNetworkResult, ThreadFunctionsResult, + ThreadPageLoadResult, ProfileLogsResult, MarkerFilterOptions, FunctionFilterOptions, @@ -981,6 +983,24 @@ export class ProfileQuerier { return { ...result, context: this._getContext() }; } + /** + * Summarize a page load: navigation timing, resource stats, CPU categories, and jank. + */ + async threadPageLoad( + threadHandle?: string, + options?: { navigationIndex?: number; jankLimit?: number } + ): Promise> { + const result = collectThreadPageLoad( + this._store, + this._threadMap, + this._timestampManager, + this._markerMap, + threadHandle, + options + ); + return { ...result, context: this._getContext() }; + } + /** * Extract Log-type markers from the profile in MOZ_LOG format. * Iterates all threads by default; supports filtering by thread, module, level, search, and limit. diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 704c6d2d59..88265f9152 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -546,6 +546,66 @@ export type StackTraceData = { truncated: boolean; }; +// ===== Thread Page Load Command ===== + +export type NavigationMilestone = { + name: string; // 'FCP', 'LCP', 'DCL', 'Load', 'TTI' + timeMs: number; // relative to navStart + markerHandle: string; // e.g. "m-3" +}; + +export type PageLoadResourceEntry = { + filename: string; // last URL path segment, truncated to 50 chars + url: string; + durationMs: number; + resourceType: string; // 'JS', 'CSS', 'Image', 'HTML', 'JSON', 'Font', 'Wasm', 'Other' + markerHandle: string; // e.g. "m-5" +}; + +export type PageLoadCategoryEntry = { + name: string; + count: number; + percentage: number; +}; + +export type JankFunction = { + name: string; + sampleCount: number; +}; + +export type JankPeriod = { + startMs: number; // relative to navStart + durationMs: number; + markerHandle: string; // e.g. "m-7" + startHandle: string; // timestamp handle for zoom, e.g. "ts-3" + endHandle: string; // timestamp handle for zoom, e.g. "ts-4" + topFunctions: JankFunction[]; + categories: PageLoadCategoryEntry[]; +}; + +export type ThreadPageLoadResult = { + type: 'thread-page-load'; + threadHandle: string; + friendlyThreadName: string; + url: string | null; + navigationIndex: number; // 1-based + navigationTotal: number; + navStartMs: number; // absolute profile time of nav start + milestones: NavigationMilestone[]; + // Resources + resourceCount: number; + resourceAvgMs: number | null; + resourceMaxMs: number | null; + resourcesByType: Array<{ type: string; count: number; percentage: number }>; + topResources: PageLoadResourceEntry[]; // top 10 by duration + // CPU categories + totalSamples: number; + categories: PageLoadCategoryEntry[]; + // Jank + jankTotal: number; + jankPeriods: JankPeriod[]; // limited by jankLimit +}; + // ===== Profile Commands ===== export type ProfileInfoResult = { From 3b13932a97f7beecd46d1fd6e1358f7a12695b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Thu, 16 Apr 2026 13:56:58 +0200 Subject: [PATCH 56/63] Bump the alpha release --- profiler-cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/profiler-cli/package.json b/profiler-cli/package.json index baa1a0a439..8d39ab6dd0 100644 --- a/profiler-cli/package.json +++ b/profiler-cli/package.json @@ -1,6 +1,6 @@ { "name": "@firefox-devtools/profiler-cli", - "version": "0.1.0-alpha.0", + "version": "0.1.0-alpha.1", "description": "Command-line interface for querying Firefox Profiler profiles with persistent daemon sessions", "scripts": { "prepublishOnly": "test -f dist/profiler-cli.js || (echo 'Run yarn build-profiler-cli from the repo root first' && exit 1)" From bb21a7ddd58d8d7fb8487164b52b35961396394f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Thu, 16 Apr 2026 14:03:00 +0200 Subject: [PATCH 57/63] Add --version to the cli --- profiler-cli/src/constants.ts | 8 +++++++- profiler-cli/src/index.ts | 2 ++ scripts/build-profiler-cli.mjs | 7 ++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/profiler-cli/src/constants.ts b/profiler-cli/src/constants.ts index 03c7e476bc..3050cbaaac 100644 --- a/profiler-cli/src/constants.ts +++ b/profiler-cli/src/constants.ts @@ -6,11 +6,17 @@ * Build-time constants injected by the build script. */ -// This global is defined via esbuild's define option. +// These globals are defined via esbuild's define option. declare const __BUILD_HASH__: string; +declare const __VERSION__: string; /** * Unique hash for this build, used to detect version mismatches * between client and daemon. */ export const BUILD_HASH = __BUILD_HASH__; + +/** + * Package version from profiler-cli/package.json, injected at build time. + */ +export const VERSION = __VERSION__; diff --git a/profiler-cli/src/index.ts b/profiler-cli/src/index.ts index 09476ea3c6..47b172d6cf 100644 --- a/profiler-cli/src/index.ts +++ b/profiler-cli/src/index.ts @@ -32,6 +32,7 @@ import { startNewDaemon, stopDaemon, sendCommand } from './client'; import { listSessions } from './session'; import { formatOutput } from './output'; import { addGlobalOptions } from './commands/shared'; +import { VERSION } from './constants'; import { registerProfileCommand } from './commands/profile'; import { registerThreadCommand } from './commands/thread'; import { registerMarkerCommand } from './commands/marker'; @@ -68,6 +69,7 @@ async function main(): Promise { program .name('profiler-cli') .description('Profiler CLI — query Firefox profiles from the terminal') + .version(VERSION, '-V, --version', 'Print the version number') .helpOption('-h, --help', 'Show help') .addHelpCommand('help [command]', 'Show help for a command') .addHelpText( diff --git a/scripts/build-profiler-cli.mjs b/scripts/build-profiler-cli.mjs index 611db96737..4cff0ef1d9 100644 --- a/scripts/build-profiler-cli.mjs +++ b/scripts/build-profiler-cli.mjs @@ -2,9 +2,13 @@ * 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/. */ import esbuild from 'esbuild'; -import { chmodSync } from 'fs'; +import { chmodSync, readFileSync } from 'fs'; import { nodeBaseConfig } from './lib/esbuild-configs.mjs'; +const { version } = JSON.parse( + readFileSync(new URL('../profiler-cli/package.json', import.meta.url), 'utf8') +); + const BUILD_HASH = Date.now().toString(36); const profilerCliConfig = { @@ -18,6 +22,7 @@ const profilerCliConfig = { }, define: { __BUILD_HASH__: JSON.stringify(BUILD_HASH), + __VERSION__: JSON.stringify(version), }, external: [...nodeBaseConfig.external, 'gecko-profiler-demangle'], }; From df0a064645ac3f33809387d412c039b864acb5d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 17 Apr 2026 14:26:41 +0200 Subject: [PATCH 58/63] Add a way to list all the markers and not only the aggregate --- profiler-cli/README.md | 3 +- profiler-cli/guide.txt | 12 ++ profiler-cli/src/commands/thread.ts | 5 +- profiler-cli/src/formatters.ts | 16 ++ profiler-cli/src/protocol.ts | 1 + .../src/test/unit/marker-formatting.test.ts | 150 ++++++++++++++ src/profile-query/formatters/marker-info.ts | 49 +++++ src/profile-query/types.ts | 12 ++ .../marker-info-formatters.test.ts | 192 ++++++++++++++++++ 9 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 profiler-cli/src/test/unit/marker-formatting.test.ts diff --git a/profiler-cli/README.md b/profiler-cli/README.md index d64b657627..f3bad341e0 100644 --- a/profiler-cli/README.md +++ b/profiler-cli/README.md @@ -39,7 +39,7 @@ profiler-cli thread select # Select a thread (e.g., t-0, t-1) profiler-cli thread samples # Show hot functions list for current thread profiler-cli thread samples-top-down # Show top-down call tree (where CPU time is spent) profiler-cli thread samples-bottom-up # Show bottom-up call tree (what calls hot functions) -profiler-cli thread markers # List markers with aggregated statistics +profiler-cli thread markers # List markers with aggregated statistics [--list for flat per-marker view] profiler-cli thread functions # List all functions with CPU percentages profiler-cli thread page-load # Show page load summary (navigation timing, resources, CPU, jank) profiler-cli marker info # Show detailed marker information (e.g., m-1234) @@ -90,6 +90,7 @@ profiler-cli thread info --thread t-0 # View info for specific thread witho | `--scoring ` | Call tree scoring: `exponential-0.95`, `exponential-0.9` (default), `exponential-0.8`, `harmonic-0.1`, `harmonic-0.5`, `harmonic-1.0`, `percentage-only` | | `--navigation ` | Select which navigation to show in `thread page-load` (1-based, default: last completed) | | `--jank-limit ` | Max jank periods to show in `thread page-load` (default: 10, 0 = show all) | +| `--list` | Show a flat chronological list of individual markers (for `thread markers`) | | `--all` | Show all threads in `profile info` (overrides default top-5 limit) | | `--session ` | Use a specific session instead of the current one | diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index a1feff4dbd..1ed7ebf242 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -64,6 +64,8 @@ CORE WORKFLOW profiler-cli thread markers --search DOMEvent Filter by name substring profiler-cli thread markers --min-duration 10 Only markers >= 10ms profiler-cli thread markers --has-stack Only markers with stack traces + profiler-cli thread markers --list Flat chronological list (one row per marker) + profiler-cli thread markers --search X --list List all individual X markers with handles profiler-cli thread network First 20 requests with timing phases (default) profiler-cli thread network --limit 0 All requests (no limit) @@ -268,6 +270,12 @@ COMMON ANALYSIS PATTERNS profiler-cli thread markers --auto-group profiler-cli thread markers --group-by type,name + List all occurrences of a specific marker type: + profiler-cli thread markers --search "Histogram::Add" --list + profiler-cli thread markers --search "Paint" --list | head -50 + profiler-cli thread markers --search "GC" --min-duration 5 --list + profiler-cli thread markers --list --limit 100 First 100 markers in chronological order + Investigate a page load: profiler-cli thread page-load Overview: milestones, top resources, CPU categories, jank profiler-cli thread page-load --navigation 2 If multiple navigations, inspect each one separately (1-based) @@ -354,6 +362,10 @@ SCRIPTING profiler-cli thread markers --json | \ jq -r '[.byType[].topMarkers[] | select(.duration > 50) | .handle][]' + # List all handles for a specific marker type (flat list mode) + profiler-cli thread markers --search "Histogram::Add" --list --json | \ + jq -r '.flatMarkers[].handle' + SESSION MANAGEMENT profiler-cli session list List all running daemon sessions (* marks current) diff --git a/profiler-cli/src/commands/thread.ts b/profiler-cli/src/commands/thread.ts index c9d8ba22a5..739b7faa7b 100644 --- a/profiler-cli/src/commands/thread.ts +++ b/profiler-cli/src/commands/thread.ts @@ -242,6 +242,7 @@ export function registerThreadCommand( '--top-n ', 'Number of top markers to include per group in JSON output (default: 5)' ) + .option('--list', 'Show a flat chronological list of individual markers') ).action(async (opts) => { let markerFilters: MarkerFilterOptions | undefined; @@ -254,7 +255,8 @@ export function registerThreadCommand( opts.limit !== undefined || opts.groupBy !== undefined || opts.autoGroup || - opts.topN !== undefined + opts.topN !== undefined || + opts.list ) { markerFilters = {}; if (opts.search !== undefined) markerFilters.searchString = opts.search; @@ -262,6 +264,7 @@ export function registerThreadCommand( if (opts.hasStack) markerFilters.hasStack = true; if (opts.autoGroup) markerFilters.autoGroup = true; if (opts.groupBy !== undefined) markerFilters.groupBy = opts.groupBy; + if (opts.list) markerFilters.list = true; if (opts.minDuration !== undefined) { const minDuration = parseFloat(opts.minDuration); diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 04f3459eef..ba33846d07 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -769,6 +769,22 @@ export function formatThreadMarkersResult( return lines.join('\n'); } + // Flat list mode: one row per marker in chronological order + if (result.flatMarkers) { + const rootStart = result.context.rootRange.start; + for (const m of result.flatMarkers) { + const stackIndicator = m.hasStack ? '✓' : '✗'; + const startStr = `t=${formatDuration(m.start - rootStart)}`; + const durationStr = + m.duration !== undefined ? formatDuration(m.duration) : 'instant'; + const labelSuffix = m.label !== m.name ? ` ${m.label}` : ''; + lines.push( + ` ${m.handle.padEnd(8)} ${m.name.padEnd(30)} ${startStr.padEnd(14)} ${durationStr.padEnd(10)} ${stackIndicator}${labelSuffix}` + ); + } + return lines.join('\n'); + } + // Handle custom grouping if present if (result.customGroups && result.customGroups.length > 0) { formatMarkerGroupsForDisplay(lines, result.customGroups, 0); diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts index 94d0287f82..931f631ed5 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -10,6 +10,7 @@ // Re-export shared types from profile-query export type { MarkerFilterOptions, + FlatMarkerItem, FunctionFilterOptions, SampleFilterSpec, FilterEntry, diff --git a/profiler-cli/src/test/unit/marker-formatting.test.ts b/profiler-cli/src/test/unit/marker-formatting.test.ts new file mode 100644 index 0000000000..7fca40e992 --- /dev/null +++ b/profiler-cli/src/test/unit/marker-formatting.test.ts @@ -0,0 +1,150 @@ +/* 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/. */ + +import { formatThreadMarkersResult } from '../../formatters'; +import type { + ThreadMarkersResult, + FlatMarkerItem, + SessionContext, + WithContext, +} from 'firefox-profiler/profile-query/types'; + +function createContext(): SessionContext { + return { + selectedThreadHandle: 't-0', + selectedThreads: [{ threadIndex: 0, name: 'GeckoMain' }], + currentViewRange: null, + rootRange: { start: 0, end: 3000 }, + }; +} + +function makeResult( + overrides: Partial = {} +): WithContext { + return { + context: createContext(), + type: 'thread-markers', + threadHandle: 't-0', + friendlyThreadName: 'GeckoMain', + totalMarkerCount: 10, + filteredMarkerCount: 10, + byType: [], + byCategory: [], + ...overrides, + }; +} + +function makeFlat(overrides: Partial = {}): FlatMarkerItem { + return { + handle: 'm-1', + name: 'DOMEvent', + label: 'DOMEvent', + start: 100, + hasStack: false, + category: 'DOM', + ...overrides, + }; +} + +describe('formatThreadMarkersResult flat list mode', function () { + it('renders one line per flat marker', function () { + const result = makeResult({ + filteredMarkerCount: 2, + flatMarkers: [ + makeFlat({ handle: 'm-1', name: 'DOMEvent', label: 'DOMEvent' }), + makeFlat({ handle: 'm-2', name: 'DOMEvent', label: 'DOMEvent' }), + ], + }); + + const output = formatThreadMarkersResult(result); + const markerLines = output + .split('\n') + .filter((l) => l.includes('m-1') || l.includes('m-2')); + expect(markerLines).toHaveLength(2); + }); + + it('shows handle and marker name on each line', function () { + const result = makeResult({ + filteredMarkerCount: 1, + flatMarkers: [makeFlat({ handle: 'm-42', name: 'Paint' })], + }); + + const output = formatThreadMarkersResult(result); + expect(output).toContain('m-42'); + expect(output).toContain('Paint'); + }); + + it('appends label suffix when label differs from name', function () { + const result = makeResult({ + filteredMarkerCount: 1, + flatMarkers: [ + makeFlat({ name: 'DOMEvent', label: 'click', handle: 'm-10' }), + ], + }); + + const output = formatThreadMarkersResult(result); + const line = output.split('\n').find((l) => l.includes('m-10'))!; + expect(line).toContain('click'); + }); + + it('does not add label suffix when label equals name', function () { + const result = makeResult({ + filteredMarkerCount: 1, + flatMarkers: [ + makeFlat({ name: 'Paint', label: 'Paint', handle: 'm-20' }), + ], + }); + + const output = formatThreadMarkersResult(result); + const line = output.split('\n').find((l) => l.includes('m-20'))!; + // "Paint" appears once (as the name), not twice + expect(line.indexOf('Paint')).toBe(line.lastIndexOf('Paint')); + }); + + it('shows "instant" for markers without duration', function () { + const result = makeResult({ + filteredMarkerCount: 1, + flatMarkers: [makeFlat({ duration: undefined })], + }); + + const output = formatThreadMarkersResult(result); + expect(output).toContain('instant'); + }); + + it('shows formatted duration for interval markers', function () { + const result = makeResult({ + filteredMarkerCount: 1, + flatMarkers: [makeFlat({ duration: 5 })], + }); + + const output = formatThreadMarkersResult(result); + expect(output).toContain('5.00ms'); + expect(output).not.toContain('instant'); + }); + + it('shows stack indicator', function () { + const result = makeResult({ + filteredMarkerCount: 2, + flatMarkers: [ + makeFlat({ handle: 'm-1', hasStack: true }), + makeFlat({ handle: 'm-2', hasStack: false }), + ], + }); + + const output = formatThreadMarkersResult(result); + expect(output).toContain('✓'); + expect(output).toContain('✗'); + }); + + it('does not show aggregated By Name header in flat list mode', function () { + const result = makeResult({ + filteredMarkerCount: 1, + flatMarkers: [makeFlat()], + }); + + const output = formatThreadMarkersResult(result); + expect(output).not.toContain('By Name'); + expect(output).not.toContain('By Category'); + }); +}); diff --git a/src/profile-query/formatters/marker-info.ts b/src/profile-query/formatters/marker-info.ts index c6ddaca913..c2bd972f0d 100644 --- a/src/profile-query/formatters/marker-info.ts +++ b/src/profile-query/formatters/marker-info.ts @@ -43,6 +43,7 @@ import type { DurationStats, RateStats, MarkerFilterOptions, + FlatMarkerItem, ProfileLogsResult, } from '../types'; import { @@ -772,6 +773,28 @@ export function formatThreadMarkers( return lines.join('\n'); } + if (filterOptions.list) { + for (const markerIndex of filteredIndexes) { + const marker = fullMarkerList[markerIndex]; + const handle = markerMap.handleForMarker(threadIndexes, markerIndex); + const stackIndicator = + marker.data && 'cause' in marker.data && marker.data.cause + ? '✓' + : '✗'; + const startStr = `t=${formatDuration(marker.start)}`; + const durationStr = + marker.end !== null + ? formatDuration(marker.end - marker.start) + : 'instant'; + const label = getMarkerLabel(markerIndex); + const labelSuffix = label && label !== marker.name ? ` ${label}` : ''; + lines.push( + ` ${handle.padEnd(8)} ${marker.name.padEnd(30)} ${startStr.padEnd(14)} ${durationStr.padEnd(10)} ${stackIndicator}${labelSuffix}` + ); + } + return lines.join('\n'); + } + const { groupBy, autoGroup, topN } = filterOptions; const maxTopMarkers = topN ?? 5; @@ -1063,6 +1086,31 @@ export function collectThreadMarkers( } : undefined; + let flatMarkers: FlatMarkerItem[] | undefined; + if (filterOptions.list) { + flatMarkers = []; + for (const markerIndex of filteredIndexes) { + const marker = fullMarkerList[markerIndex]; + const handle = markerMap.handleForMarker(threadIndexes, markerIndex); + const duration = + marker.end !== null ? marker.end - marker.start : undefined; + const hasStack = Boolean( + marker.data && 'cause' in marker.data && marker.data.cause + ); + const categoryName = categories[marker.category]?.name ?? 'Other'; + const label = getMarkerLabel(markerIndex); + flatMarkers.push({ + handle, + name: marker.name, + label: label || marker.name, + start: marker.start, + duration, + hasStack, + category: categoryName, + }); + } + } + return { type: 'thread-markers', threadHandle: displayThreadHandle, @@ -1073,6 +1121,7 @@ export function collectThreadMarkers( byType, byCategory, customGroups, + flatMarkers, }; } finally { // Always clear the search string to avoid affecting other queries diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 88265f9152..f83d29be6f 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -35,6 +35,17 @@ export type MarkerFilterOptions = { groupBy?: string; // Grouping strategy (e.g., "type,name" or "type,field:eventType") autoGroup?: boolean; // Automatically determine grouping based on field variance topN?: number; // Number of top markers to include per group in JSON output (default: 5) + list?: boolean; // Return a flat chronological list of all individual markers +}; + +export type FlatMarkerItem = { + handle: string; + name: string; + label: string; // Schema-derived table label (may equal name if no schema) + start: number; // Absolute milliseconds + duration?: number; // Milliseconds if interval marker + hasStack: boolean; + category: string; }; export type FunctionFilterOptions = { @@ -431,6 +442,7 @@ export type ThreadMarkersResult = { percentage: number; }>; customGroups?: MarkerGroupData[]; + flatMarkers?: FlatMarkerItem[]; }; export type DurationStats = { diff --git a/src/test/unit/profile-query/marker-info-formatters.test.ts b/src/test/unit/profile-query/marker-info-formatters.test.ts index 1ef36d0f54..0bf4bf00e7 100644 --- a/src/test/unit/profile-query/marker-info-formatters.test.ts +++ b/src/test/unit/profile-query/marker-info-formatters.test.ts @@ -332,6 +332,198 @@ describe('collectThreadMarkers topN option', function () { }); }); +describe('collectThreadMarkers list option', function () { + it('returns flatMarkers when list: true', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, 10, { type: 'DOMEvent', eventType: 'click', latency: 5 }], + ['DOMEvent', 20, null, { type: 'DOMEvent', eventType: 'keydown' }], + ]); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { + list: true, + } + ); + + expect(result.flatMarkers).toBeDefined(); + expect(result.flatMarkers).toHaveLength(2); + }); + + it('flatMarkers is undefined without list option', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, 10, { type: 'DOMEvent', eventType: 'click', latency: 5 }], + ]); + + const result = collectThreadMarkers(store, threadMap, markerMap); + + expect(result.flatMarkers).toBeUndefined(); + }); + + it('each flat marker has correct fields', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 5, 15, { type: 'DOMEvent', eventType: 'click', latency: 1 }], + ]); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { + list: true, + } + ); + + const m = result.flatMarkers![0]; + expect(m.handle).toMatch(/^m-/); + expect(m.name).toBe('DOMEvent'); + expect(m.start).toBe(5); + expect(m.duration).toBe(10); + expect(m.hasStack).toBe(false); + expect(m.category).toBeDefined(); + }); + + it('instant markers have undefined duration', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 5, null, { type: 'DOMEvent', eventType: 'scroll' }], + ]); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { + list: true, + } + ); + + expect(result.flatMarkers![0].duration).toBeUndefined(); + }); + + it('uses schema-derived label separate from name', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, 10, { type: 'DOMEvent', eventType: 'click', latency: 5 }], + ]); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { + list: true, + } + ); + + const m = result.flatMarkers![0]; + expect(m.name).toBe('DOMEvent'); + // DOMEvent table label includes the eventType from the schema + expect(m.label).toContain('click'); + expect(m.label).not.toBe(m.name); + }); + + it('search filter applies to flat list', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, 5, { type: 'DOMEvent', eventType: 'click', latency: 1 }], + [ + 'UserTiming', + 10, + 15, + { type: 'UserTiming', name: 'myMark', entryType: 'measure' }, + ], + ]); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { + list: true, + searchString: 'DOMEvent', + } + ); + + expect(result.flatMarkers).toHaveLength(1); + expect(result.flatMarkers![0].name).toBe('DOMEvent'); + }); +}); + +describe('formatThreadMarkers list option', function () { + it('shows one row per marker with handle and name', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, 10, { type: 'DOMEvent', eventType: 'click', latency: 5 }], + [ + 'DOMEvent', + 20, + 30, + { type: 'DOMEvent', eventType: 'keydown', latency: 2 }, + ], + ]); + + const result = formatThreadMarkers(store, threadMap, markerMap, undefined, { + list: true, + }); + + expect(result).toMatch(/m-\d+/); + expect(result).toContain('DOMEvent'); + }); + + it('does not show aggregated By Name header', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, 10, { type: 'DOMEvent', eventType: 'click', latency: 5 }], + ]); + + const result = formatThreadMarkers(store, threadMap, markerMap, undefined, { + list: true, + }); + + expect(result).not.toContain('By Name'); + }); + + it('appends schema label when it differs from the marker name', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, null, { type: 'DOMEvent', eventType: 'click' }], + ]); + + const result = formatThreadMarkers(store, threadMap, markerMap, undefined, { + list: true, + }); + + expect(result).toContain('click'); + }); + + it('shows "instant" for instant markers and omits it for interval markers', function () { + const { store, threadMap, markerMap } = setupWithMarkers([ + ['DOMEvent', 0, null, { type: 'DOMEvent', eventType: 'click' }], + [ + 'DOMEvent', + 10, + 20, + { type: 'DOMEvent', eventType: 'keydown', latency: 1 }, + ], + ]); + + const result = formatThreadMarkers(store, threadMap, markerMap, undefined, { + list: true, + }); + + expect(result).toContain('instant'); + // Interval marker should show a duration, not "instant" + const lines = result.split('\n').filter((l) => l.includes('m-')); + expect(lines).toHaveLength(2); + const instantLine = lines.find((l) => l.includes('instant')); + const intervalLine = lines.find((l) => !l.includes('instant')); + expect(instantLine).toBeDefined(); + expect(intervalLine).toBeDefined(); + }); +}); + describe('collectMarkerStack and formatMarkerStackFull', function () { describe('for a marker without a cause', function () { it('collectMarkerStack returns null stack', function () { From b7a378f1a54acdd0af6b39c8b2d79040d0a8352c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 17 Apr 2026 17:17:50 +0200 Subject: [PATCH 59/63] Bump the alpha release --- profiler-cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/profiler-cli/package.json b/profiler-cli/package.json index 8d39ab6dd0..9260e96848 100644 --- a/profiler-cli/package.json +++ b/profiler-cli/package.json @@ -1,6 +1,6 @@ { "name": "@firefox-devtools/profiler-cli", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "description": "Command-line interface for querying Firefox Profiler profiles with persistent daemon sessions", "scripts": { "prepublishOnly": "test -f dist/profiler-cli.js || (echo 'Run yarn build-profiler-cli from the repo root first' && exit 1)" From bb4c42b62cc3d6298844685445b3216c0f872f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 17 Apr 2026 19:31:56 +0200 Subject: [PATCH 60/63] Symbolicate unsymbolicated profiles during profile load in cli It also accepts a --symbol-server to be able to point to a custom server. --- profiler-cli/src/client.ts | 24 ++++++- profiler-cli/src/daemon.ts | 37 +++++++++-- profiler-cli/src/index.ts | 12 +++- profiler-cli/src/protocol.ts | 1 + src/profile-query/index.ts | 12 +++- src/profile-query/loader.ts | 119 ++++++++++++++++++++++++++++++++++- 6 files changed, 192 insertions(+), 13 deletions(-) diff --git a/profiler-cli/src/client.ts b/profiler-cli/src/client.ts index 3c6c681283..39977fc16f 100644 --- a/profiler-cli/src/client.ts +++ b/profiler-cli/src/client.ts @@ -233,7 +233,8 @@ export async function sendCommand( export async function startNewDaemon( sessionDir: string, profilePath: string, - sessionId?: string + sessionId?: string, + symbolServerUrl?: string ): Promise { // Check if this is a URL const isUrl = @@ -267,10 +268,21 @@ export async function startNewDaemon( // Get the path to the current script (profiler-cli.js) const scriptPath = process.argv[1]; + const daemonArgs = [ + scriptPath, + '--daemon', + absolutePath, + '--session', + targetSessionId, + ]; + if (symbolServerUrl) { + daemonArgs.push('--symbol-server', symbolServerUrl); + } + // Spawn the daemon process (detached from parent) const child = child_process.spawn( process.execPath, // node - [scriptPath, '--daemon', absolutePath, '--session', targetSessionId], + daemonArgs, { detached: true, stdio: 'ignore', // Don't pipe stdin/stdout/stderr @@ -306,6 +318,7 @@ export async function startNewDaemon( // Phase 2: Wait for profile to load by checking status (longer timeout) const profileLoadMaxAttempts = 600; // 600 * 100ms = 60 seconds attempts = 0; + let printedSymbolicating = false; while (attempts < profileLoadMaxAttempts) { await new Promise((resolve) => setTimeout(resolve, 100)); @@ -323,6 +336,13 @@ export async function startNewDaemon( // Still loading, keep waiting continue; + case 'symbolicating': + if (!printedSymbolicating) { + console.log('Symbolicating...'); + printedSymbolicating = true; + } + continue; + case 'error': // Profile load failed, fail immediately throw new Error(response.error); diff --git a/profiler-cli/src/daemon.ts b/profiler-cli/src/daemon.ts index 70dd05cf64..1d7e0499c1 100644 --- a/profiler-cli/src/daemon.ts +++ b/profiler-cli/src/daemon.ts @@ -38,13 +38,21 @@ export class Daemon { private logPath: string; private logStream: fs.WriteStream; private profilePath: string; + private symbolServerUrl?: string; private loadingProfile: boolean = false; + private symbolicating: boolean = false; private profileLoadError: Error | null = null; - constructor(sessionDir: string, profilePath: string, sessionId?: string) { + constructor( + sessionDir: string, + profilePath: string, + sessionId?: string, + symbolServerUrl?: string + ) { this.sessionDir = sessionDir; this.profilePath = profilePath; this.sessionId = sessionId || generateSessionId(); + this.symbolServerUrl = symbolServerUrl; this.socketPath = getSocketPath(sessionDir, this.sessionId); this.logPath = getLogPath(sessionDir, this.sessionId); this.logStream = fs.createWriteStream(this.logPath, { flags: 'a' }); @@ -135,14 +143,24 @@ export class Daemon { this.loadingProfile = true; try { console.log('Loading profile...'); - this.querier = await ProfileQuerier.load(this.profilePath); - console.log('Profile loaded successfully'); + this.querier = await ProfileQuerier.load( + this.profilePath, + this.symbolServerUrl, + () => { + this.loadingProfile = false; + this.symbolicating = true; + console.log('Symbolicating profile...'); + } + ); + this.symbolicating = false; this.loadingProfile = false; + console.log('Profile loaded successfully'); } catch (error) { console.error(`Failed to load profile: ${error}`); this.profileLoadError = error instanceof Error ? error : new Error(String(error)); this.loadingProfile = false; + this.symbolicating = false; } } @@ -216,6 +234,9 @@ export class Daemon { if (this.loadingProfile) { return { type: 'loading' }; } + if (this.symbolicating) { + return { type: 'symbolicating' }; + } if (this.querier) { return { type: 'ready' }; } @@ -460,8 +481,14 @@ export class Daemon { export async function startDaemon( sessionDir: string, profilePath: string, - sessionId?: string + sessionId?: string, + symbolServerUrl?: string ): Promise { - const daemon = new Daemon(sessionDir, profilePath, sessionId); + const daemon = new Daemon( + sessionDir, + profilePath, + sessionId, + symbolServerUrl + ); await daemon.start(); } diff --git a/profiler-cli/src/index.ts b/profiler-cli/src/index.ts index 47b172d6cf..5ee26fa4eb 100644 --- a/profiler-cli/src/index.ts +++ b/profiler-cli/src/index.ts @@ -57,11 +57,14 @@ async function main(): Promise { ); const sessionIdx = rawArgs.indexOf('--session'); const sessionId = sessionIdx !== -1 ? rawArgs[sessionIdx + 1] : undefined; + const symbolServerIdx = rawArgs.indexOf('--symbol-server'); + const symbolServerUrl = + symbolServerIdx !== -1 ? rawArgs[symbolServerIdx + 1] : undefined; if (!profilePath) { console.error('Error: Profile path required for daemon mode'); process.exit(1); } - await startDaemon(SESSION_DIR, profilePath, sessionId); + await startDaemon(SESSION_DIR, profilePath, sessionId, symbolServerUrl); return; } @@ -100,12 +103,17 @@ Examples: program .command('load ') .description('Load a profile and start a daemon session') + .option( + '--symbol-server ', + 'Symbol server URL for symbolication (overrides URL param and default Mozilla server)' + ) ).action(async (profilePath: string, opts) => { console.log(`Loading profile from ${profilePath}...`); const sessionId = await startNewDaemon( SESSION_DIR, profilePath, - opts.session + opts.session, + opts.symbolServer ); console.log(`Session started: ${sessionId}`); }); diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts index 931f631ed5..6c851dedf3 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -166,6 +166,7 @@ export type ServerResponse = | { type: 'success'; result: string | CommandResult } | { type: 'error'; error: string } | { type: 'loading' } + | { type: 'symbolicating' } | { type: 'ready' }; /** diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 55ea972ae2..cd48f2765b 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -161,8 +161,16 @@ export class ProfileQuerier { }); } - static async load(filePathOrUrl: string): Promise { - const { store, rootRange } = await loadProfileFromFileOrUrl(filePathOrUrl); + static async load( + filePathOrUrl: string, + symbolServerUrl?: string, + onSymbolicating?: () => void + ): Promise { + const { store, rootRange } = await loadProfileFromFileOrUrl( + filePathOrUrl, + symbolServerUrl, + onSymbolicating + ); return new ProfileQuerier(store, rootRange); } diff --git a/src/profile-query/loader.ts b/src/profile-query/loader.ts index d3b0cdbc69..c425bcc1e0 100644 --- a/src/profile-query/loader.ts +++ b/src/profile-query/loader.ts @@ -14,7 +14,15 @@ import { } from '../utils/profile-fetch'; import type { TemporaryError } from '../utils/errors'; import type { Store } from '../types/store'; -import type { StartEndRange } from 'firefox-profiler/types'; +import type { StartEndRange, Profile } from 'firefox-profiler/types'; +import { SymbolStore } from '../profile-logic/symbol-store'; +import { + symbolicateProfile, + applySymbolicationSteps, +} from '../profile-logic/symbolication'; +import type { SymbolicationStepInfo } from '../profile-logic/symbolication'; +import * as MozillaSymbolicationAPI from '../profile-logic/mozilla-symbolication-api'; +import { SYMBOL_SERVER_URL } from '../app-logic/constants'; /** * Helper function to detect if the input is a URL @@ -23,6 +31,61 @@ function isUrl(input: string): boolean { return input.startsWith('http://') || input.startsWith('https://'); } +/** + * Extract a ?symbolServer= query parameter from a profiler.firefox.com URL. + */ +function extractSymbolServerFromUrl(url: string): string | undefined { + try { + return new URL(url).searchParams.get('symbolServer') ?? undefined; + } catch (_) { + return undefined; + } +} + +async function symbolicateInPlace( + profile: Profile, + symbolServerUrl: string, + onSymbolicating: () => void +): Promise { + onSymbolicating(); + console.log(`Symbolicating profile using ${symbolServerUrl}...`); + + const symbolStore = new SymbolStore({ + requestSymbolsFromServer: async (requests) => { + return MozillaSymbolicationAPI.requestSymbols( + 'symbol server', + requests, + async (path, json) => { + const response = await fetch(symbolServerUrl + path, { + body: json, + method: 'POST', + }); + return response.json(); + } + ); + }, + requestSymbolsFromBrowser: async () => [], + requestSymbolsViaSymbolTableFromBrowser: async () => { + throw new Error('Not supported in this context'); + }, + }); + + const symbolicationSteps: SymbolicationStepInfo[] = []; + await symbolicateProfile(profile, symbolStore, (step) => { + symbolicationSteps.push(step); + }); + + const { shared, threads } = applySymbolicationSteps( + profile.threads, + profile.shared, + symbolicationSteps + ); + profile.shared = shared; + profile.threads = threads; + profile.meta.symbolicated = true; + console.log('Symbolication complete'); +} + /** * Helper function to follow redirects and get the final URL. * This is useful for short URLs like https://share.firefox.dev/4oLEjCw @@ -43,13 +106,31 @@ export interface LoadResult { /** * Load a profile from a file path or URL. * Returns a store and root range that can be used to construct a ProfileQuerier. + * + * If the profile is not already symbolicated, symbolication is performed + * automatically. The symbol server URL is resolved in this order: + * 1. Explicit `symbolServerUrl` argument + * 2. `?symbolServer=` query param in the input URL (for profiler.firefox.com URLs) + * 3. The default Mozilla symbolication server + * + * `onSymbolicating` is called just before symbolication begins, so callers can + * update their loading state (e.g. to show a "Symbolicating…" message). */ export async function loadProfileFromFileOrUrl( - filePathOrUrl: string + filePathOrUrl: string, + symbolServerUrl?: string, + onSymbolicating?: () => void ): Promise { const store = createStore(); console.log(`Loading profile from ${filePathOrUrl}`); + // Extract ?symbolServer= from the URL. For short URLs (share.firefox.dev) + // we re-extract after following the redirect, since the param lives on the + // resolved profiler.firefox.com URL, not the short URL itself. + let urlSymbolServer = isUrl(filePathOrUrl) + ? extractSymbolServerFromUrl(filePathOrUrl) + : undefined; + if (isUrl(filePathOrUrl)) { // Handle URL input let finalUrl = filePathOrUrl; @@ -65,6 +146,7 @@ export async function loadProfileFromFileOrUrl( console.log('Following redirect from short URL...'); finalUrl = await followRedirects(filePathOrUrl); console.log(`Redirected to: ${finalUrl}`); + urlSymbolServer = extractSymbolServerFromUrl(finalUrl); } // Extract the profile URL from the profiler.firefox.com URL @@ -104,6 +186,13 @@ export async function loadProfileFromFileOrUrl( throw new Error('Unable to parse the profile.'); } + await maybeSymbolicate( + profile, + symbolServerUrl, + urlSymbolServer, + onSymbolicating + ); + await store.dispatch(loadProfile(profile, {}, true)); await store.dispatch(finalizeProfileView()); const state = store.getState(); @@ -123,9 +212,35 @@ export async function loadProfileFromFileOrUrl( throw new Error('Unable to parse the profile.'); } + await maybeSymbolicate( + profile, + symbolServerUrl, + urlSymbolServer, + onSymbolicating + ); + await store.dispatch(loadProfile(profile, {}, true)); await store.dispatch(finalizeProfileView()); const state = store.getState(); const rootRange = getProfileRootRange(state); return { store, rootRange }; } + +async function maybeSymbolicate( + profile: Profile, + symbolServerUrl: string | undefined, + urlSymbolServer: string | undefined, + onSymbolicating: (() => void) | undefined +): Promise { + if (profile.meta.symbolicated === true) { + return; + } + const serverUrl = symbolServerUrl ?? urlSymbolServer ?? SYMBOL_SERVER_URL; + try { + await symbolicateInPlace(profile, serverUrl, onSymbolicating ?? (() => {})); + } catch (e) { + console.warn( + `Symbolication failed: ${e}. Loading profile without symbols.` + ); + } +} From 4cf5e4b182d13b7400a2eb7624d629759dd393e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Tue, 21 Apr 2026 14:19:14 +0200 Subject: [PATCH 61/63] Update nits --- profiler-cli/src/commands/thread.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/profiler-cli/src/commands/thread.ts b/profiler-cli/src/commands/thread.ts index 739b7faa7b..e711a68218 100644 --- a/profiler-cli/src/commands/thread.ts +++ b/profiler-cli/src/commands/thread.ts @@ -259,12 +259,24 @@ export function registerThreadCommand( opts.list ) { markerFilters = {}; - if (opts.search !== undefined) markerFilters.searchString = opts.search; - if (opts.category !== undefined) markerFilters.category = opts.category; - if (opts.hasStack) markerFilters.hasStack = true; - if (opts.autoGroup) markerFilters.autoGroup = true; - if (opts.groupBy !== undefined) markerFilters.groupBy = opts.groupBy; - if (opts.list) markerFilters.list = true; + if (opts.search !== undefined) { + markerFilters.searchString = opts.search; + } + if (opts.category !== undefined) { + markerFilters.category = opts.category; + } + if (opts.hasStack) { + markerFilters.hasStack = true; + } + if (opts.autoGroup) { + markerFilters.autoGroup = true; + } + if (opts.groupBy !== undefined) { + markerFilters.groupBy = opts.groupBy; + } + if (opts.list) { + markerFilters.list = true; + } if (opts.minDuration !== undefined) { const minDuration = parseFloat(opts.minDuration); From 679d91849f6c8d5eacf5a63ebc175aa4027aa99a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Tue, 21 Apr 2026 14:15:47 +0200 Subject: [PATCH 62/63] Bump the alpha version --- profiler-cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/profiler-cli/package.json b/profiler-cli/package.json index 9260e96848..95ae2d0392 100644 --- a/profiler-cli/package.json +++ b/profiler-cli/package.json @@ -1,6 +1,6 @@ { "name": "@firefox-devtools/profiler-cli", - "version": "0.1.0-alpha.2", + "version": "0.1.0-alpha.4", "description": "Command-line interface for querying Firefox Profiler profiles with persistent daemon sessions", "scripts": { "prepublishOnly": "test -f dist/profiler-cli.js || (echo 'Run yarn build-profiler-cli from the repo root first' && exit 1)" From a9fdc860b182df5e0d279a927588a09ed7162e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Tue, 21 Apr 2026 14:47:39 +0200 Subject: [PATCH 63/63] Skip symbolication in cli tests --- profiler-cli/src/daemon.ts | 4 +++- .../src/test/integration/sessions.test.ts | 22 ++++--------------- profiler-cli/src/test/integration/utils.ts | 11 +++++----- src/profile-query/index.ts | 6 +++-- src/profile-query/loader.ts | 14 +++++++----- 5 files changed, 25 insertions(+), 32 deletions(-) diff --git a/profiler-cli/src/daemon.ts b/profiler-cli/src/daemon.ts index 1d7e0499c1..5013d6cd50 100644 --- a/profiler-cli/src/daemon.ts +++ b/profiler-cli/src/daemon.ts @@ -143,6 +143,7 @@ export class Daemon { this.loadingProfile = true; try { console.log('Loading profile...'); + const skipSymbolication = process.env.PROFILER_CLI_NO_SYMBOLICATE === '1'; this.querier = await ProfileQuerier.load( this.profilePath, this.symbolServerUrl, @@ -150,7 +151,8 @@ export class Daemon { this.loadingProfile = false; this.symbolicating = true; console.log('Symbolicating profile...'); - } + }, + skipSymbolication ); this.symbolicating = false; this.loadingProfile = false; diff --git a/profiler-cli/src/test/integration/sessions.test.ts b/profiler-cli/src/test/integration/sessions.test.ts index 1b1ee29113..258d8028ef 100644 --- a/profiler-cli/src/test/integration/sessions.test.ts +++ b/profiler-cli/src/test/integration/sessions.test.ts @@ -30,9 +30,8 @@ describe('profiler-cli multiple concurrent sessions', () => { it('can run multiple sessions with explicit IDs', async () => { const session1 = 'test-session-1'; const session2 = 'test-session-2'; - const session3 = 'test-session-3'; - // Start three sessions + // Start two sessions await cli(ctx, [ 'load', 'src/test/fixtures/upgrades/processed-1.json', @@ -45,31 +44,18 @@ describe('profiler-cli multiple concurrent sessions', () => { '--session', session2, ]); - await cli(ctx, [ - 'load', - 'src/test/fixtures/upgrades/processed-3.json', - '--session', - session3, - ]); - // Query each session explicitly + // Query session1 explicitly const result1 = await cli(ctx, ['profile', 'info', '--session', session1]); expect(result1.stdout).toContain('This profile contains'); - const result2 = await cli(ctx, ['profile', 'info', '--session', session2]); + // Query current session (should be session2, the last loaded) + const result2 = await cli(ctx, ['profile', 'info']); expect(result2.stdout).toContain('This profile contains'); - // Query current session (should be session3) - const result3 = await cli(ctx, ['profile', 'info']); - expect(result3.stdout).toContain('This profile contains'); - - // Note: We don't assert that results differ, as different test profiles - // might coincidentally have identical summaries. - // Stop all sessions (mix of positional arg and --session flag) await cli(ctx, ['stop', session1]); await cli(ctx, ['stop', '--session', session2]); - await cli(ctx, ['stop', session3]); }); it('session list shows running sessions and marks the current one', async () => { diff --git a/profiler-cli/src/test/integration/utils.ts b/profiler-cli/src/test/integration/utils.ts index 0ce375bd8c..3e6e0602ce 100644 --- a/profiler-cli/src/test/integration/utils.ts +++ b/profiler-cli/src/test/integration/utils.ts @@ -68,11 +68,7 @@ function exec( if (timedOut) { reject(new Error(`Command timed out after ${options.timeout}ms`)); } else { - resolve({ - stdout, - stderr, - exitCode: code ?? 1, - }); + resolve({ stdout, stderr, exitCode: code ?? 1 }); } }); @@ -101,7 +97,10 @@ export async function createTestContext(): Promise { const sessionDir = await mkdtemp(join(tmpdir(), 'profiler-cli-test-')); return { sessionDir, - env: { PROFILER_CLI_SESSION_DIR: sessionDir }, + env: { + PROFILER_CLI_SESSION_DIR: sessionDir, + PROFILER_CLI_NO_SYMBOLICATE: '1', + }, }; } diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index cd48f2765b..c65615eb43 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -164,12 +164,14 @@ export class ProfileQuerier { static async load( filePathOrUrl: string, symbolServerUrl?: string, - onSymbolicating?: () => void + onSymbolicating?: () => void, + skipSymbolication?: boolean ): Promise { const { store, rootRange } = await loadProfileFromFileOrUrl( filePathOrUrl, symbolServerUrl, - onSymbolicating + onSymbolicating, + skipSymbolication ); return new ProfileQuerier(store, rootRange); } diff --git a/src/profile-query/loader.ts b/src/profile-query/loader.ts index c425bcc1e0..5927fd9bb2 100644 --- a/src/profile-query/loader.ts +++ b/src/profile-query/loader.ts @@ -119,7 +119,8 @@ export interface LoadResult { export async function loadProfileFromFileOrUrl( filePathOrUrl: string, symbolServerUrl?: string, - onSymbolicating?: () => void + onSymbolicating?: () => void, + skipSymbolication?: boolean ): Promise { const store = createStore(); console.log(`Loading profile from ${filePathOrUrl}`); @@ -190,7 +191,8 @@ export async function loadProfileFromFileOrUrl( profile, symbolServerUrl, urlSymbolServer, - onSymbolicating + onSymbolicating, + skipSymbolication ); await store.dispatch(loadProfile(profile, {}, true)); @@ -216,7 +218,8 @@ export async function loadProfileFromFileOrUrl( profile, symbolServerUrl, urlSymbolServer, - onSymbolicating + onSymbolicating, + skipSymbolication ); await store.dispatch(loadProfile(profile, {}, true)); @@ -230,9 +233,10 @@ async function maybeSymbolicate( profile: Profile, symbolServerUrl: string | undefined, urlSymbolServer: string | undefined, - onSymbolicating: (() => void) | undefined + onSymbolicating: (() => void) | undefined, + skipSymbolication?: boolean ): Promise { - if (profile.meta.symbolicated === true) { + if (profile.meta.symbolicated === true || skipSymbolication === true) { return; } const serverUrl = symbolServerUrl ?? urlSymbolServer ?? SYMBOL_SERVER_URL;