From e039a6dedbbe9a79d0d3b9c481f967a728b35ad3 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 25 Jul 2025 21:14:45 -0400 Subject: [PATCH 1/8] Add utilities for summarizing CPU usage timelines in a textual form. --- src/profile-logic/combined-cpu.ts | 134 ++++++++++++++ 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.ts | 73 ++++++++ src/test/unit/combined-cpu.test.ts | 98 +++++++++++ src/utils/slice-tree.ts | 205 ++++++++++++++++++++++ src/utils/window-console.ts | 10 ++ 8 files changed, 659 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.ts 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..a7a7efa163 --- /dev/null +++ b/src/profile-logic/combined-cpu.ts @@ -0,0 +1,134 @@ +/* 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 by scanning each thread's + * cursor for the next-lowest time on every step + * 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 0fbab489ab..29aa887f94 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; @@ -400,6 +416,7 @@ export function getBasicThreadSelectorsPerThread( getThread, getSamplesTable, getTracedValuesBuffer, + getActivitySlices, getSamplesWeightType, getNativeAllocations, getJsAllocations, diff --git a/src/selectors/profile.ts b/src/selectors/profile.ts index 331b27ed6f..2807de8e06 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, @@ -721,6 +726,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.ts b/src/test/unit/activity-slice-tree.test.ts new file mode 100644 index 0000000000..95af457973 --- /dev/null +++ b/src/test/unit/activity-slice-tree.test.ts @@ -0,0 +1,73 @@ +/* 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 { 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..c2289b2b7b --- /dev/null +++ b/src/utils/slice-tree.ts @@ -0,0 +1,205 @@ +/* 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; + } + + 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 04b8d93e1f6937e8459e56efbb04b3034eb852be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 25 Jul 2025 21:14:45 -0400 Subject: [PATCH 2/8] Add commander as a dependency --- package.json | 1 + yarn.lock | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/package.json b/package.json index 8259763f14..24e9024eb9 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "array-range": "^1.0.1", "clamp": "^1.0.1", "classnames": "^2.5.1", + "commander": "^14.0.3", "common-tags": "^1.8.2", "copy-to-clipboard": "^4.0.2", "core-js": "^3.49.0", diff --git a/yarn.lock b/yarn.lock index 15acf9b8cc..b949fc354a 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" From 679203125d652078e92d9856238a6d6c683271af 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 3/8] 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 05ba86b2bc..5bf82499af 100644 --- a/src/profile-logic/transforms.ts +++ b/src/profile-logic/transforms.ts @@ -317,6 +317,15 @@ export function parseTransforms(transformString: string): TransformStack { const filterString = filter.join('-'); const filterType = convertToFullFilterType(shortFilterType); + if (filterType !== 'marker-search') { + // profiler-cli-only filter types are not supported in the frontend. + console.error( + 'A profiler-cli-only filter-samples type was found in the URL and will be ignored.', + filterType + ); + break; + } + transforms.push({ type: 'filter-samples', filterType, @@ -338,6 +347,15 @@ function convertToFullFilterType(shortFilterType: string): FilterSamplesType { switch (shortFilterType) { case 'm': return 'marker-search'; + // profiler-cli-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.'); } @@ -350,6 +368,15 @@ function convertToShortFilterType(filterType: FilterSamplesType): string { switch (filterType) { case 'marker-search': return 'm'; + // profiler-cli-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); } @@ -457,6 +484,14 @@ export function getTransformLabelL10nIds( 'TransformNavigator--drop-samples-outside-of-markers-matching', item: transform.filter, }; + // profiler-cli-only filter types: + case 'outside-marker': + case 'function-include': + case 'stack-prefix': + case 'stack-suffix': + throw new Error( + `getTransformLabelL10nIds: profiler-cli-only filter type "${transform.filterType}" is not supported in the frontend transform navigator.` + ); default: throw assertExhaustiveCheck(transform.filterType); } @@ -1730,70 +1765,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 - ); }); } @@ -2066,9 +2200,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 0773185967..c99a1953de 100644 --- a/src/test/store/transforms.test.ts +++ b/src/test/store/transforms.test.ts @@ -2229,6 +2229,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 0837ced0d7..7abf9cc69b 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 profiler-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 @@ -369,13 +384,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 ef57c36fa014d3099e89c3a5541ae6f6ce849c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 22 Apr 2026 00:01:34 +0200 Subject: [PATCH 4/8] Extract POP_TRANSFORMS_FROM_STACK to an action creator It will be used later by the profiler-cli. --- src/actions/profile-view.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 7584c1f7be..51ff3dcdb4 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -1860,11 +1860,20 @@ export function popTransformsFromStack( ): ThunkAction { return (dispatch, getState) => { const threadsKey = getSelectedThreadsKey(getState()); - dispatch({ - type: 'POP_TRANSFORMS_FROM_STACK', - threadsKey, - firstPoppedFilterIndex, - }); + dispatch( + popTransformsFromStackForThreads(threadsKey, firstPoppedFilterIndex) + ); + }; +} + +export function popTransformsFromStackForThreads( + threadsKey: ThreadsKey, + firstPoppedFilterIndex: number +): Action { + return { + type: 'POP_TRANSFORMS_FROM_STACK', + threadsKey, + firstPoppedFilterIndex, }; } From fb1039d889d73bfe97c6dfd1971fe316fbe814f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 25 Jul 2025 21:14:45 -0400 Subject: [PATCH 5/8] Fix extractGeckoLogs and extract log marker logic related functions to profile-logic --- src/profile-logic/marker-data.ts | 79 +++++++++++++++++++ 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 ++++++--------- 6 files changed, 129 insertions(+), 47 deletions(-) diff --git a/src/profile-logic/marker-data.ts b/src/profile-logic/marker-data.ts index 97695c0722..a984f89afa 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,81 @@ 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"). + */ +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/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 9f15b29104..47f085fde0 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 847db9a67b0304291041f87f7d4b124101711fc2 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 25 Jul 2025 21:14:45 -0400 Subject: [PATCH 6/8] Create a profile-query library and a profile-query-cli script --- .gitignore | 1 + .prettierignore | 1 + docs-developer/deploying.md | 58 + eslint.config.mjs | 1 + package.json | 5 + profiler-cli/CONTRIBUTING.md | 238 +++ profiler-cli/LICENSE | 373 +++++ profiler-cli/README.md | 125 ++ profiler-cli/guide.txt | 415 +++++ profiler-cli/package.json | 44 + profiler-cli/schemas.txt | 121 ++ profiler-cli/src/client.ts | 401 +++++ profiler-cli/src/commands/filter.ts | 110 ++ profiler-cli/src/commands/function.ts | 88 + profiler-cli/src/commands/marker.ts | 49 + profiler-cli/src/commands/profile.ts | 98 ++ profiler-cli/src/commands/session.ts | 82 + profiler-cli/src/commands/shared.ts | 137 ++ profiler-cli/src/commands/thread.ts | 474 ++++++ profiler-cli/src/commands/zoom.ts | 58 + profiler-cli/src/constants.ts | 22 + profiler-cli/src/daemon.ts | 470 ++++++ profiler-cli/src/formatters.ts | 1516 ++++++++++++++++++ profiler-cli/src/index.ts | 192 +++ profiler-cli/src/output.ts | 94 ++ profiler-cli/src/protocol.ts | 207 +++ profiler-cli/src/session.ts | 239 +++ profiler-cli/src/utils/parse.ts | 207 +++ scripts/build-profile-query.mjs | 19 + scripts/build-profiler-cli.mjs | 36 + scripts/publish-profiler-cli.mjs | 32 + scripts/verify-profiler-cli-build.mjs | 34 + src/profile-logic/call-tree.ts | 4 + src/profile-query/README.md | 50 + src/profile-query/cpu-activity.ts | 118 ++ src/profile-query/filter-stack.ts | 223 +++ src/profile-query/formatters/call-tree.ts | 326 ++++ src/profile-query/formatters/marker-info.ts | 1442 +++++++++++++++++ src/profile-query/formatters/page-load.ts | 503 ++++++ src/profile-query/formatters/profile-info.ts | 169 ++ src/profile-query/formatters/thread-info.ts | 456 ++++++ src/profile-query/function-annotate.ts | 394 +++++ src/profile-query/function-list.ts | 533 ++++++ src/profile-query/function-map.ts | 35 + src/profile-query/index.ts | 1161 ++++++++++++++ src/profile-query/loader.ts | 292 ++++ src/profile-query/marker-map.ts | 70 + src/profile-query/process-thread-list.ts | 208 +++ src/profile-query/thread-map.ts | 64 + src/profile-query/time-range-parser.ts | 63 + src/profile-query/timestamps.ts | 311 ++++ src/profile-query/types.ts | 686 ++++++++ src/selectors/per-thread/thread.tsx | 20 + src/selectors/profile.ts | 33 + src/types/globals/global.d.ts | 5 + tsconfig.json | 7 +- 56 files changed, 13119 insertions(+), 1 deletion(-) create mode 100644 profiler-cli/CONTRIBUTING.md create mode 100644 profiler-cli/LICENSE create mode 100644 profiler-cli/README.md create mode 100644 profiler-cli/guide.txt create mode 100644 profiler-cli/package.json create mode 100644 profiler-cli/schemas.txt create mode 100644 profiler-cli/src/client.ts create mode 100644 profiler-cli/src/commands/filter.ts create mode 100644 profiler-cli/src/commands/function.ts create mode 100644 profiler-cli/src/commands/marker.ts create mode 100644 profiler-cli/src/commands/profile.ts create mode 100644 profiler-cli/src/commands/session.ts create mode 100644 profiler-cli/src/commands/shared.ts create mode 100644 profiler-cli/src/commands/thread.ts create mode 100644 profiler-cli/src/commands/zoom.ts create mode 100644 profiler-cli/src/constants.ts create mode 100644 profiler-cli/src/daemon.ts create mode 100644 profiler-cli/src/formatters.ts create mode 100644 profiler-cli/src/index.ts create mode 100644 profiler-cli/src/output.ts create mode 100644 profiler-cli/src/protocol.ts create mode 100644 profiler-cli/src/session.ts create mode 100644 profiler-cli/src/utils/parse.ts create mode 100644 scripts/build-profile-query.mjs create mode 100644 scripts/build-profiler-cli.mjs create mode 100644 scripts/publish-profiler-cli.mjs create mode 100644 scripts/verify-profiler-cli-build.mjs create mode 100644 src/profile-query/README.md create mode 100644 src/profile-query/cpu-activity.ts create mode 100644 src/profile-query/filter-stack.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/page-load.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-annotate.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 diff --git a/.gitignore b/.gitignore index a3bd91ed88..82de1fd4d8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ webpack.local-config.js *.orig *.rej .idea/ +.profiler-cli-dev/ diff --git a/.prettierignore b/.prettierignore index 93d9f5be03..f4266c7c45 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ src/profile-logic/import/proto src/types/libdef/npm +profiler-cli/dist docs-user/js docs-user/css src/test/fixtures/upgrades diff --git a/docs-developer/deploying.md b/docs-developer/deploying.md index 33975e5f1c..543b9408d2 100644 --- a/docs-developer/deploying.md +++ b/docs-developer/deploying.md @@ -97,3 +97,61 @@ To deploy on nginx (without support for direct upload from the Firefox UI), run and point nginx at the `dist` directory, which needs to be at the root of the webserver. Additionally, a `error_page 404 =200 /index.html;` directive needs to be added so that unknown URLs respond with index.html. For a more production-ready configuration, have a look at the netlify [`_headers`](/res/_headers) file. + +# Publishing profiler-cli to npm + +The [`@firefox-devtools/profiler-cli`](https://www.npmjs.com/package/@firefox-devtools/profiler-cli) +package is published to npm from this repository. It provides a command-line +interface for querying Firefox Profiler profiles — see +[`profiler-cli/README.md`](../profiler-cli/README.md) for usage. + +## Prerequisites + +- Be logged in to npm (`npm login`) with publish access to the `@firefox-devtools` scope. +- Make sure the working tree is clean and you are on the commit you want to publish. +- Run `yarn test-all` (or at least `yarn test-cli`) to confirm the CLI still builds and passes tests. + +## Bump the version + +Edit the `version` field in [`profiler-cli/package.json`](../profiler-cli/package.json), +then land the version bump on `main` before publishing. + +## Publish + +From the repository root: + +``` +yarn publish-profiler-cli +``` + +[`scripts/publish-profiler-cli.mjs`](../scripts/publish-profiler-cli.mjs) will: + +1. Run `yarn build-profiler-cli` to produce `profiler-cli/dist/profiler-cli.js` (a + single self-contained bundle with no runtime dependencies). +2. Run `npm publish profiler-cli/`, picking `--tag alpha` when the version + contains `-` (e.g. `0.1.0-alpha.5`) and `--tag latest` otherwise. +3. Trigger the `prepublishOnly` hook in `profiler-cli/package.json`, which runs + [`scripts/verify-profiler-cli-build.mjs`](../scripts/verify-profiler-cli-build.mjs) + to confirm the bundle exists and embeds the current `package.json` version — + this guards against publishing a stale build. + +Extra arguments are forwarded to `npm publish`. For example: + +``` +# Build and verify, but do not actually publish. +yarn publish-profiler-cli --dry-run + +# Override the automatic dist-tag. +yarn publish-profiler-cli --tag alpha +``` + +## Verify the release + +After publishing, confirm the new version is listed on +[npm](https://www.npmjs.com/package/@firefox-devtools/profiler-cli) and installs +cleanly: + +``` +npm install -g @firefox-devtools/profiler-cli@latest +profiler-cli --version +``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 5bb495b649..add4776217 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/**', + 'profiler-cli/dist/**', 'res/**', 'dist/**', 'node-tools-dist/**', diff --git a/package.json b/package.json index 24e9024eb9..6c2dd2ad53 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,11 @@ "build-sw": "workbox generateSW workbox-config.js", "build-prod:quiet": "yarn build-prod", "build-node-tools": "cross-env NODE_ENV=production node scripts/build-node-tools.mjs", + "build-profile-query": "cross-env NODE_ENV=production node scripts/build-profile-query.mjs", + "build-profile-query:quiet": "yarn build-profile-query", + "build-profiler-cli": "cross-env NODE_ENV=production node scripts/build-profiler-cli.mjs", + "build-profiler-cli:quiet": "yarn build-profiler-cli", + "publish-profiler-cli": "node scripts/publish-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", diff --git a/profiler-cli/CONTRIBUTING.md b/profiler-cli/CONTRIBUTING.md new file mode 100644 index 0000000000..f8f82b54ad --- /dev/null +++ b/profiler-cli/CONTRIBUTING.md @@ -0,0 +1,238 @@ +# 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. The CLI daemon is just an IPC wrapper around it — query logic belongs in `src/profile-query/`, not in `daemon.ts`. + +## 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 self-contained executable to `profiler-cli/dist/profiler-cli.js` with zero runtime dependencies +- **Published artifact**: `profiler-cli/dist/profiler-cli.js` is published to npm as `@firefox-devtools/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, or `$PROFILER_CLI_LOAD_TIMEOUT_MS`) 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):** + +See `src/protocol.ts` for the authoritative `ClientMessage`, `ClientCommand`, and `ServerResponse` type definitions. + +**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 + +**Current session pointer:** + +- `current.txt` is a plain-text file containing the active session ID +- Resolved to the full socket path 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--`, +where `` is derived from the session directory path to avoid cross-directory collisions. + +## 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 + +Each command group lives in its own file under `commands/`. To add a new command, modify the following files. 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: Create `commands/allocation.ts` + +```typescript +import { Command } from 'commander'; +import { sendCommand } from '../client'; +import { addGlobalOptions } from './shared'; +import { formatOutput } from '../output'; + +export function registerAllocationCommand( + program: Command, + sessionDir: string +): void { + const allocation = program + .command('allocation') + .description('Allocation commands'); + + addGlobalOptions( + allocation + .command('info') + .description('Show allocation summary') + .option('--thread ', 'Thread to query') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { command: 'allocation', subcommand: 'info', thread: opts.thread }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} +``` + +### Step 3: Handle the command in `daemon.ts` + +Add a case to `processMessage()`: + +```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` and wire it into `output.ts` + +```typescript +// formatters.ts +export function formatAllocationInfoResult( + result: WithContext +): string { + const lines: string[] = [formatContextHeader(result.context)]; + lines.push(`Total allocated: ${result.totalBytes} bytes`); + return lines.join('\n'); +} + +// output.ts — add a case to the formatOutput switch +case 'allocation-info': + return formatAllocationInfoResult(result); +``` + +### Step 6: Register the command and update docs + +```typescript +// index.ts — add alongside the other register* calls +registerAllocationCommand(program, SESSION_DIR); +``` + +Then: + +- Add the command to `README.md` +- Remove it from "Known Gaps" 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/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/README.md b/profiler-cli/README.md new file mode 100644 index 0000000000..d2916d04a2 --- /dev/null +++ b/profiler-cli/README.md @@ -0,0 +1,125 @@ +# Firefox Profiler CLI + +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-devtools/profiler-cli@latest +``` + +Requires Node.js >= 24. + +## Quick Start + +```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 +``` + +`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. + +## Commands + +```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 +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 [--list for flat per-marker view] +profiler-cli thread functions # List all functions with CPU percentages +profiler-cli thread network # Show network requests with timing phases [--search] [--min-duration] [--max-duration] [--limit] +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) +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) +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 + +```bash +profiler-cli load --session +profiler-cli profile info --session +``` + +### 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 +``` + +## Options + +| Flag | Description | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--thread ` | Specify thread (e.g., `t-0`) | +| `--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 | +| `--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) | +| `--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 | + +## 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`). + +## Session Storage + +Sessions are stored in `~/.profiler-cli/` (or `$PROFILER_CLI_SESSION_DIR` to override). + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture, build instructions, and how to add new commands. + +## License + +[MPL-2.0](https://www.mozilla.org/en-US/MPL/2.0/) diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt new file mode 100644 index 0000000000..bc1cca6bfb --- /dev/null +++ b/profiler-cli/guide.txt @@ -0,0 +1,415 @@ +profiler-cli: Usage Guide + +profiler-cli (Profiler CLI) queries Firefox performance profiles loaded into a persistent +daemon. Load a profile once, then query it interactively without reloading. + + +QUICK START + + 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 + + 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". + + The rest of this guide covers filtering, zooming, markers, and advanced analysis. + + +CORE WORKFLOW + + Step 1: Load a profile + profiler-cli load profile.json.gz + profiler-cli load https://share.firefox.dev/XXXXXX From a profiler.firefox.com / share URL + profiler-cli load profile.json.gz --session run-a Recommended for scripting or concurrent work + + Step 2: Explore the profile + 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) + + Step 4: Analyze CPU usage + 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: + 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 + profiler-cli thread samples-top-down --search "GC,malloc" paths through both GC and malloc + - "|" 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 + 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 + 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) + 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 + + 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 + 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) + + 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 + + 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 "profiler-cli status" to inspect all session state at any time. + + +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") + + 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 + + Zoom restricts all subsequent queries to a specific time window. Useful for + drilling into a specific jank period, page load phase, or marker duration. + + 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 "profiler-cli 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. + + 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, per-thread) + Use "filter push" to add a filter to the current thread's stack. Every + subsequent analysis command sees all pushed filters automatically. Exactly + one filter flag per "filter push" command. + + profiler-cli filter push --merge f-142,f-143 # add filter 1 + profiler-cli filter push --includes-function f-500 # add filter 2 + profiler-cli filter list # one row per entry, in push order + profiler-cli filter pop # undo the last push + profiler-cli filter pop 2 # undo the last 2 pushes + profiler-cli filter clear # remove all + + Each "filter push" is one entry, even when the flag takes a comma-separated + list (e.g. "--merge f-1,f-2" is a single entry and a single pop). + + Loading a profiler.firefox.com URL that encodes transforms adds them as + entries too; each URL transform is its own entry, so "filter pop" removes + them one at a time. "filter clear" removes everything. + + Use "profiler-cli status" to inspect the full session state (selected thread, + zoom stack, per-thread filters). + + Sticky + ephemeral compose: sticky filters apply first, then ephemeral flags + 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-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 + + 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: + 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 + + Add --json to any command to get structured JSON output, suitable for piping to jq + or processing programmatically. + + 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 "profiler-cli schemas" for the full JSON schema reference. + + +COMMON ANALYSIS PATTERNS + + 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 + 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: + 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: + 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: + 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: + 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: + 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 + 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) + 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) + profiler-cli thread network --search "api" Filter by URL substring + profiler-cli thread markers --category Network Cross-reference with marker view + + +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. + + 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. + samples-bottom-up Hot leaf functions at top with their callers. + Use to understand what calls a frequently-seen function. + +TIPS + + - Start with "profile info"; GeckoMain is usually the main thread -- use + "--search GeckoMain" to find it quickly + - 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 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 + with specific event types + - Check "profiler-cli status" after any state changes to confirm selected thread, active + zoom, and active filters before running analysis + + +SCRIPTING + + When using profiler-cli in scripts or pipelines: + + Always use a named session for isolation: + 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. + + 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 + profiler-cli profile info --json | \ + jq -r '.processes[].threads[] | select(.name | test("GeckoMain")) | .threadHandle' + + # Get the top self-time function handle + profiler-cli thread samples --json | jq -r '.topFunctionsBySelf[0].functionHandle' + + # List marker handles for markers longer than 50ms + 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) + 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 load profile.json.gz --symbol-server Override symbol server (else ?symbolServer= URL param or Mozilla default) + profiler-cli thread info --session my-session Query a specific session + + +ERROR HANDLING + + "No running session" + 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 "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 "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 + "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 + "profiler-cli status" (check the profile path). + + "Session not found" / "No such session" + 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 ~/.profiler-cli/.log contains the full error detail. diff --git a/profiler-cli/package.json b/profiler-cli/package.json new file mode 100644 index 0000000000..1c62db31c4 --- /dev/null +++ b/profiler-cli/package.json @@ -0,0 +1,44 @@ +{ + "name": "@firefox-devtools/profiler-cli", + "version": "0.1.0-alpha.4", + "description": "Command-line interface for querying Firefox Profiler profiles with persistent daemon sessions", + "scripts": { + "prepublishOnly": "node ../scripts/verify-profiler-cli-build.mjs" + }, + "main": "./dist/profiler-cli.js", + "bin": { + "profiler-cli": "dist/profiler-cli.js", + "pq": "dist/profiler-cli.js" + }, + "files": [ + "dist/profiler-cli.js" + ], + "engines": { + "node": ">= 24" + }, + "devEngines": { + "runtime": { + "name": "node", + "version": ">= 24" + } + }, + "keywords": [ + "profiler", + "firefox", + "performance", + "profiling", + "cli", + "performance-analysis" + ], + "author": "Mozilla DevTools", + "license": "MPL-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/firefox-devtools/profiler.git", + "directory": "profiler-cli" + }, + "homepage": "https://profiler.firefox.com", + "bugs": { + "url": "https://github.com/firefox-devtools/profiler/issues" + } +} diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt new file mode 100644 index 0000000000..2a13b1389f --- /dev/null +++ b/profiler-cli/schemas.txt @@ -0,0 +1,121 @@ +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. + +SessionContext (present on all command results): + { + selectedThreadHandle, + selectedThreads: [{ threadIndex, name }], + currentViewRange: { start, startName, end, endName } | null, + rootRange: { start, end } + } + + +profiler-cli 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 + } + +profiler-cli 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 + } + +profiler-cli thread samples-top-down --json + { + type: "thread-samples-top-down", + threadHandle, friendlyThreadName, activeOnly?, + regularCallTree: CallTreeNode, + activeFilters?, ephemeralFilters?, + context: SessionContext + } + +profiler-cli 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 } + } + +profiler-cli 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 + } + +profiler-cli 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 + } + +profiler-cli 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 } + } + +profiler-cli status --json + { + type: "status", + selectedThreadHandle, + selectedThreads: [{ threadIndex, name }], + viewRanges: [{ start, startName, end, endName }], + rootRange: { start, end }, + filterStacks: [{ threadHandle, filters: FilterEntry[] }] + } diff --git a/profiler-cli/src/client.ts b/profiler-cli/src/client.ts new file mode 100644 index 0000000000..b1c3c17661 --- /dev/null +++ b/profiler-cli/src/client.ts @@ -0,0 +1,401 @@ +/* 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 profiler-cli 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, + 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) { + socket.destroy(); + 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. + */ +async function sendRawMessage( + sessionDir: string, + message: ClientMessage, + sessionId?: string +): Promise { + const resolvedSessionId = sessionId || getCurrentSessionId(sessionDir); + + if (!resolvedSessionId) { + throw new Error('No active session. Run "profiler-cli 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) { + 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}). ${shutdownMessage} Please run "profiler-cli load " again.` + ); + } + + const socketPath = sessionId + ? getSocketPath(sessionDir, sessionId) + : getCurrentSocketPath(sessionDir); + + if (!socketPath) { + throw new Error(`Socket not found for session ${resolvedSessionId}`); + } + + return sendMessageToSocket(socketPath, message); +} + +/** + * 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, + symbolServerUrl?: 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(); + + 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 (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 + daemonArgs, + { + detached: true, + stdio: 'ignore', // Don't pipe stdin/stdout/stderr + env: { ...process.env, PROFILER_CLI_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). + // Override with PROFILER_CLI_LOAD_TIMEOUT_MS env var for large profiles. + const loadTimeoutMs = process.env.PROFILER_CLI_LOAD_TIMEOUT_MS + ? parseInt(process.env.PROFILER_CLI_LOAD_TIMEOUT_MS, 10) + : 60_000; + const profileLoadMaxAttempts = Math.ceil(loadTimeoutMs / 100); + attempts = 0; + let printedSymbolicating = false; + + 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 'symbolicating': + if (!printedSymbolicating) { + console.log('Symbolicating...'); + printedSymbolicating = true; + } + 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 ${loadTimeoutMs}ms (set PROFILER_CLI_LOAD_TIMEOUT_MS to override)` + ); +} + +/** + * 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/profiler-cli/src/commands/filter.ts b/profiler-cli/src/commands/filter.ts new file mode 100644 index 0000000000..6c17ae725c --- /dev/null +++ b/profiler-cli/src/commands/filter.ts @@ -0,0 +1,110 @@ +/* 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/. */ + +/** + * `profiler-cli filter` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { parseFilterSpec } from '../utils/parse'; +import { + addGlobalOptions, + addSampleFilterOptions, + parseIntArg, + 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, + merge: opts.merge, + rootAt: opts.rootAt, + includesFunction: opts.includesFunction, + 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 = parseIntArg( + 'count', + String(raw), + 1, + 'Error: count must be a positive integer' + ); + 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: profiler-cli 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/profiler-cli/src/commands/function.ts b/profiler-cli/src/commands/function.ts new file mode 100644 index 0000000000..63c9442592 --- /dev/null +++ b/profiler-cli/src/commands/function.ts @@ -0,0 +1,88 @@ +/* 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/. */ + +/** + * `profiler-cli 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)); + }); + + 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/commands/marker.ts b/profiler-cli/src/commands/marker.ts new file mode 100644 index 0000000000..d84c93e6dc --- /dev/null +++ b/profiler-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/. */ + +/** + * `profiler-cli 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/profiler-cli/src/commands/profile.ts b/profiler-cli/src/commands/profile.ts new file mode 100644 index 0000000000..582080324c --- /dev/null +++ b/profiler-cli/src/commands/profile.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/. */ + +/** + * `profiler-cli profile` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { addGlobalOptions, parseIntArg } 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)); + }); + + 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 = parseIntArg('--limit', opts.limit, 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/commands/session.ts b/profiler-cli/src/commands/session.ts new file mode 100644 index 0000000000..6ad3e4f4f4 --- /dev/null +++ b/profiler-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/. */ + +/** + * `profiler-cli 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: profiler-cli 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/profiler-cli/src/commands/shared.ts b/profiler-cli/src/commands/shared.ts new file mode 100644 index 0000000000..38079ced9e --- /dev/null +++ b/profiler-cli/src/commands/shared.ts @@ -0,0 +1,137 @@ +/* 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 profiler-cli commands. + */ + +import type { Command } from 'commander'; +import { Option } from 'commander'; +import { collectStrings } from '../utils/parse'; + +/** + * Parse a string as an integer and exit with an error if it is not a valid + * integer >= min. Pass a custom `msg` to override the default error message. + */ +export function parseIntArg( + flagName: string, + value: string, + min: number, + msg?: string +): number { + const v = parseInt(value, 10); + if (isNaN(v) || v < min) { + console.error( + msg ?? + `Error: ${flagName} must be a ${min <= 0 ? 'non-negative' : 'positive'} integer` + ); + process.exit(1); + } + return v; +} + +/** + * Parse a string as a float in [min, max] and exit with an error on failure. + */ +export function parseFloatArg( + flagName: string, + value: string, + min: number, + max: number = Infinity, + msg?: string +): number { + const v = parseFloat(value); + if (isNaN(v) || v < min || v > max) { + console.error( + msg ?? + `Error: ${flagName} must be a number between ${min} and ${max === Infinity ? '∞' : max}` + ); + process.exit(1); + } + return v; +} + +/** + * 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. `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; + 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('--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-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/profiler-cli/src/commands/thread.ts b/profiler-cli/src/commands/thread.ts new file mode 100644 index 0000000000..4ae326e4b1 --- /dev/null +++ b/profiler-cli/src/commands/thread.ts @@ -0,0 +1,474 @@ +/* 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/. */ + +/** + * `profiler-cli thread` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { parseEphemeralFilters } from '../utils/parse'; +import { + addGlobalOptions, + addSampleFilterOptions, + parseIntArg, + parseFloatArg, +} 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 ', + 'Keep samples containing this substring in any frame. Comma-separates multiple terms, all must match (AND).' + ) + .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(', ')}` + ); +} + +function parseCallTreeOptions(opts: { + maxLines?: unknown; + scoring?: string; +}): + | { maxNodes?: number; scoringStrategy?: CallTreeScoringStrategy } + | undefined { + if (opts.maxLines === undefined && opts.scoring === undefined) { + return undefined; + } + const result: { + maxNodes?: number; + scoringStrategy?: CallTreeScoringStrategy; + } = {}; + if (opts.maxLines !== undefined) { + result.maxNodes = parseIntArg('--max-lines', String(opts.maxLines), 1); + } + if (opts.scoring !== undefined) { + if (!(VALID_SCORING_STRATEGIES as string[]).includes(opts.scoring)) { + console.error( + `Error: --scoring must be one of: ${VALID_SCORING_STRATEGIES.join(', ')}` + ); + process.exit(1); + } + result.scoringStrategy = opts.scoring as CallTreeScoringStrategy; + } + return result; +} + +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); + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'samples-top-down', + thread: opts.thread, + includeIdle: opts.includeIdle || undefined, + search: opts.search, + callTreeOptions: parseCallTreeOptions(opts), + 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); + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'samples-bottom-up', + thread: opts.thread, + includeIdle: opts.includeIdle || undefined, + search: opts.search, + callTreeOptions: parseCallTreeOptions(opts), + 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' + ) + .option( + '--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; + + if ( + opts.search !== undefined || + opts.minDuration !== undefined || + opts.maxDuration !== undefined || + opts.category !== undefined || + opts.hasStack || + opts.limit !== undefined || + opts.groupBy !== undefined || + opts.autoGroup || + opts.topN !== undefined || + 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.minDuration !== undefined) { + markerFilters.minDuration = parseFloatArg( + '--min-duration', + opts.minDuration, + 0, + Infinity, + 'Error: --min-duration must be a positive number (in milliseconds)' + ); + } + if (opts.maxDuration !== undefined) { + markerFilters.maxDuration = parseFloatArg( + '--max-duration', + opts.maxDuration, + 0, + Infinity, + 'Error: --max-duration must be a positive number (in milliseconds)' + ); + } + if (opts.limit !== undefined) { + markerFilters.limit = parseIntArg('--limit', opts.limit, 1); + } + if (opts.topN !== undefined) { + markerFilters.topN = parseIntArg('--top-n', opts.topN, 1); + } + } + + const result = await sendCommand( + sessionDir, + { + command: 'thread', + subcommand: 'markers', + thread: opts.thread, + markerFilters, + }, + opts.session + ); + 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) { + networkFilters.minDuration = parseFloatArg( + '--min-duration', + opts.minDuration, + 0, + Infinity, + 'Error: --min-duration must be a positive number (in milliseconds)' + ); + } + if (opts.maxDuration !== undefined) { + networkFilters.maxDuration = parseFloatArg( + '--max-duration', + opts.maxDuration, + 0, + Infinity, + 'Error: --max-duration must be a positive number (in milliseconds)' + ); + } + if (opts.limit !== undefined) { + networkFilters.limit = parseIntArg( + '--limit', + opts.limit, + 0, + 'Error: --limit must be a non-negative integer (0 = show all)' + ); + } 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 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) { + pageLoadOptions.navigationIndex = parseIntArg( + '--navigation', + opts.navigation, + 1, + 'Error: --navigation must be a positive integer (1-based index)' + ); + } + if (opts.jankLimit !== undefined) { + pageLoadOptions.jankLimit = parseIntArg( + '--jank-limit', + opts.jankLimit, + 0, + 'Error: --jank-limit must be a non-negative integer (0 = show all)' + ); + } + + 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( + 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) { + functionFilters.minSelf = parseFloatArg( + '--min-self', + opts.minSelf, + 0, + 100, + 'Error: --min-self must be a number between 0 and 100 (percentage)' + ); + } + if (opts.limit !== undefined) { + functionFilters.limit = parseIntArg('--limit', opts.limit, 1); + } + } + + 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/profiler-cli/src/commands/zoom.ts b/profiler-cli/src/commands/zoom.ts new file mode 100644 index 0000000000..26232030cb --- /dev/null +++ b/profiler-cli/src/commands/zoom.ts @@ -0,0 +1,58 @@ +/* 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/. */ + +/** + * `profiler-cli 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 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, + { 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/profiler-cli/src/constants.ts b/profiler-cli/src/constants.ts new file mode 100644 index 0000000000..3050cbaaac --- /dev/null +++ b/profiler-cli/src/constants.ts @@ -0,0 +1,22 @@ +/* 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. + */ + +// 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/daemon.ts b/profiler-cli/src/daemon.ts new file mode 100644 index 0000000000..de3a5ea562 --- /dev/null +++ b/profiler-cli/src/daemon.ts @@ -0,0 +1,470 @@ +/* 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 profiler-cli. + * Loads a profile and listens for commands on a Unix socket (or named pipe on Windows). + */ + +import * as net from 'net'; +import * as fs from 'fs'; +import { ProfileQuerier } from '../../src/profile-query'; +import type { LoadPhase } from '../../src/profile-query/loader'; +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 symbolServerUrl?: string; + private loadPhase: LoadPhase = 'fetching'; + private profileLoadError: Error | null = null; + + 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' }); + + // 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 { + // The daemon is spawned with stdio: 'ignore', so forwarding to the + // original console functions would just discard the output. Write + // exclusively to the log stream. + const write = (level: string, args: any[]) => { + const message = args.map((arg) => String(arg)).join(' '); + this.logStream.write( + `[${level}] ${new Date().toISOString()} ${message}\n` + ); + }; + console.log = (...args: any[]) => write('LOG', args); + console.error = (...args: any[]) => write('ERROR', args); + console.warn = (...args: any[]) => write('WARN', 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 (Unix only — named pipes on Windows are not filesystem files) + if (process.platform !== 'win32' && 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.loadPhase = 'fetching'; + try { + console.log('Loading profile...'); + const skipSymbolication = process.env.PROFILER_CLI_NO_SYMBOLICATE === '1'; + this.querier = await ProfileQuerier.load(this.profilePath, { + explicitSymbolServerUrl: this.symbolServerUrl, + skipSymbolication, + onPhaseChange: (phase) => { + this.loadPhase = phase; + if (phase === 'symbolicating') { + console.log('Symbolicating profile...'); + } + }, + }); + this.loadPhase = 'ready'; + console.log('Profile loaded successfully'); + } catch (error) { + console.error(`Failed to load profile: ${error}`); + this.profileLoadError = + error instanceof Error ? error : new Error(String(error)); + } + } + + private handleConnection(socket: net.Socket): void { + console.log('Client connected'); + + let buffer = ''; + // Serialize commands on this connection so concurrent messages cannot + // race on shared Redux state (e.g. _withEphemeralFilters). + let inFlight: Promise = Promise.resolve(); + + 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()) { + inFlight = inFlight.then(() => this.handleMessage(line, socket)); + } + } + }); + + socket.on('error', (error) => { + console.error(`Socket error: ${error}`); + }); + + socket.on('end', () => { + console.log('Client disconnected'); + }); + } + + private async handleMessage(line: string, socket: net.Socket): Promise { + try { + const message = JSON.parse(line) as ClientMessage; + console.log(`Received message: ${message.type}`); + const response = await this.processMessage(message); + socket.write(JSON.stringify(response) + '\n'); + } catch (error) { + const errorResponse: ServerResponse = { + type: 'error', + error: error instanceof Error ? error.message : String(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}`, + }; + } + switch (this.loadPhase) { + case 'fetching': + case 'processing': + return { type: 'loading' }; + case 'symbolicating': + return { type: 'symbolicating' }; + case 'ready': + if (this.querier) { + return { type: 'ready' }; + } + return { type: 'error', error: 'Profile not loaded' }; + default: + 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.loadPhase !== 'ready' || !this.querier) { + return { + type: 'error', + error: 'Profile still loading, try again shortly', + }; + } + + 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(command.all, command.search); + case 'threads': + throw new Error('unimplemented'); + case 'logs': + return this.querier!.profileLogs(command.logFilters); + 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, + command.includeIdle, + command.search, + command.sampleFilters + ); + case 'samples-top-down': + return this.querier!.threadSamplesTopDown( + command.thread, + command.callTreeOptions, + command.includeIdle, + command.search, + command.sampleFilters + ); + case 'samples-bottom-up': + return this.querier!.threadSamplesBottomUp( + command.thread, + command.callTreeOptions, + command.includeIdle, + command.search, + command.sampleFilters + ); + case 'markers': + return this.querier!.threadMarkers( + command.thread, + command.markerFilters + ); + case 'functions': + return this.querier!.threadFunctions( + command.thread, + command.functionFilters, + command.includeIdle, + command.sampleFilters + ); + case 'network': + return this.querier!.threadNetwork( + command.thread, + command.networkFilters + ); + case 'page-load': + return this.querier!.threadPageLoad( + command.thread, + command.pageLoadOptions + ); + 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'); + 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); + } + 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 '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: + 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, + symbolServerUrl?: string +): Promise { + const daemon = new Daemon( + sessionDir, + profilePath, + sessionId, + symbolServerUrl + ); + await daemon.start(); +} diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts new file mode 100644 index 0000000000..53838b8595 --- /dev/null +++ b/profiler-cli/src/formatters.ts @@ -0,0 +1,1516 @@ +/* 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. + */ + +import type { + StatusResult, + SessionContext, + WithContext, + FunctionExpandResult, + FunctionInfoResult, + FunctionAnnotateResult, + ViewRangeResult, + FilterStackResult, + ThreadInfoResult, + MarkerStackResult, + MarkerInfoResult, + ProfileInfoResult, + ThreadSamplesResult, + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + ThreadMarkersResult, + ThreadFunctionsResult, + ThreadNetworkResult, + ThreadPageLoadResult, + NetworkPhaseTimings, + MarkerGroupData, + CallTreeNode, + FilterEntry, + SampleFilterSpec, + ProfileLogsResult, + ThreadSelectResult, +} from './protocol'; +import { truncateFunctionName } from '../../src/profile-query/function-list'; +import { describeSpec } from '../../src/profile-query/filter-stack'; +import { formatTimestamp as formatDuration } from 'firefox-profiler/utils/format-numbers'; + +// Maximum display width for function names in call-tree and sample views. +const FUNC_NAME_WIDTH = 120; + +/** + * Format a SessionContext as a compact header line. + * Shows current thread selection, zoom range, and full profile duration. + */ +export function formatContextHeader( + context: SessionContext, + activeFilters?: FilterEntry[], + ephemeralFilters?: SampleFilterSpec[] +): 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; + + 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); + + const totalFilterCount = + (activeFilters?.length ?? 0) + (ephemeralFilters?.length ?? 0); + const filterInfo = + totalFilterCount > 0 ? ` | Filters: ${totalFilterCount}` : ''; + return `[Thread: ${threadInfo} | View: ${viewInfo} | Full: ${fullInfo}${filterInfo}]`; +} + +/** + * 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(' > '); + } + + 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.description}`); + } + } + const filterSection = + filterLines.length > 0 + ? '\n' + filterLines.join('\n') + : '\n Filters: none'; + + return `\ +Session Status: + Selected thread: ${threadInfo} + 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.description}`); + } + } + return lines.join('\n'); +} + +/** + * Format a FunctionExpandResult as plain text. + */ +export function formatFunctionExpandResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + return `${contextHeader} + +Function ${result.functionHandle}: +${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}: + 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 "profiler-cli zoom pop" to go back)' : ''}`; + } + } + + if (result.warning) { + output += `\nWarning: ${result.warning}`; + } + + 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} +TID: ${result.tid} +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 duration = activity.endTime - activity.startTime; + const percentage = + duration > 0 ? Math.round((activity.cpuMs / duration) * 100) : 0; + 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) { + 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++) { + 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 (relative to profile root start) + const rootStart = result.context.rootRange.start; + const startStr = formatDuration(result.start - rootStart); + if (result.end !== null) { + const endStr = formatDuration(result.end - rootStart); + const durationStr = formatDuration(result.duration!); + output += `Time: ${startStr} - ${endStr} (${durationStr})\n`; + } else { + output += `Time: ${startStr} (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: ${formatDuration(result.stack.capturedAt - rootStart)}\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 'profiler-cli 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; + } + + 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) { + // 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]`; + } + } + + 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`; + } + + 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 (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 (use --all to see all)\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 duration = activity.endTime - activity.startTime; + const percentage = + duration > 0 ? Math.round((activity.cpuMs / duration) * 100) : 0; + 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, + FUNC_NAME_WIDTH + ); + + // 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, + emptyMessage?: 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); + } + } else if (emptyMessage) { + lines.push(emptyMessage); + } + + return lines.join('\n'); +} + +function formatSamplesPreamble(result: { + context: SessionContext; + activeFilters?: FilterEntry[]; + ephemeralFilters?: SampleFilterSpec[]; + activeOnly?: boolean; + search?: string; + friendlyThreadName: string; +}): string { + 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` : ''; + return `${contextHeader}\n\nThread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}${filtersNote}`; +} + +/** + * Format a ThreadSamplesResult as plain text. + */ +export function formatThreadSamplesResult( + result: WithContext +): string { + let output = formatSamplesPreamble(result); + + 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 += + ' (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); + const displayName = truncateFunctionName( + func.nameWithLibrary, + FUNC_NAME_WIDTH + ); + 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: 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); + const displayName = truncateFunctionName( + func.nameWithLibrary, + FUNC_NAME_WIDTH + ); + 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, + FUNC_NAME_WIDTH + ); + 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, + FUNC_NAME_WIDTH + ); + 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, + FUNC_NAME_WIDTH + ); + 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 { + let output = formatSamplesPreamble(result); + + // Top-down call tree + 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; +} + +/** + * Format a ThreadSamplesBottomUpResult as plain text. + */ +export function formatThreadSamplesBottomUpResult( + result: WithContext +): string { + let output = formatSamplesPreamble(result); + + // Bottom-up call tree (inverted tree shows callers) + if (result.invertedCallTree) { + 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)'; + } + + 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'); + } + + // 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); + } else { + // Default aggregation by marker name + const W_STAT_NAME = 25; + const W_STAT_COUNT = 5; + lines.push('By Name (top 15):'); + const topTypes = result.byType.slice(0, 15); + for (const stats of topTypes) { + let line = ` ${stats.markerName.padEnd(W_STAT_NAME)} ${stats.count.toString().padStart(W_STAT_COUNT)} 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(W_STAT_NAME)} ${stats.count.toString().padStart(W_STAT_COUNT)} 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 , --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'); +} + +/** + * 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); + } + } +} + +/** + * Format a ThreadFunctionsResult as plain text. + */ +export function formatThreadFunctionsResult( + result: WithContext +): string { + const contextHeader = formatContextHeader( + result.context, + result.activeFilters, + result.ephemeralFilters + ); + 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.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.'); + 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.'); + } + return lines.join('\n'); + } + + // Show active filters if any + const filterParts: string[] = []; + if (hasFilters && result.filters) { + 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 (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):'); + for (const func of result.functions) { + const selfCount = Math.round(func.selfSamples); + const totalCount = Math.round(func.totalSamples); + const displayName = truncateFunctionName( + func.nameWithLibrary, + FUNC_NAME_WIDTH + ); + + // 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'); +} + +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'); +} + +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'); +} + +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'); +} + +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} ${formatDuration(m.timeMs)}`); + } + } + + 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) { + const W_RTYPE = 8; + const W_RCOUNT = 4; + const W_PCT = 5; + lines.push(' By type:'); + for (const t of result.resourcesByType) { + const countStr = String(t.count).padStart(W_RCOUNT); + const pctStr = t.percentage.toFixed(1).padStart(W_PCT); + lines.push(` ${t.type.padEnd(W_RTYPE)} ${countStr} (${pctStr}%)`); + } + lines.push(''); + } + + if (result.topResources.length > 0) { + const W_NUM = 3; + const W_DUR = 7; + const W_FILE = 50; + lines.push(' Top 10 longest:'); + result.topResources.forEach((r, idx) => { + const num = String(idx + 1).padStart(W_NUM); + const dur = formatDuration(r.durationMs).padStart(W_DUR); + const file = r.filename.padEnd(W_FILE); + 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 ${formatDuration(jank.startMs)} ${formatDuration(jank.durationMs)} 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'); +} + +export function formatThreadSelectResult( + result: WithContext +): string { + const count = result.threadNames.length; + const names = result.threadNames.join(', '); + if (count === 1) { + return `Selected thread: ${result.threadHandle} (${names})`; + } + return `Selected ${count} threads: ${result.threadHandle} (${names})`; +} diff --git a/profiler-cli/src/index.ts b/profiler-cli/src/index.ts new file mode 100644 index 0000000000..5ee26fa4eb --- /dev/null +++ b/profiler-cli/src/index.ts @@ -0,0 +1,192 @@ +/* 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 profiler-cli (Profiler CLI). + * + * Usage: + * 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-profiler-cli + * + * Run: + * profiler-cli (if profiler-cli is in PATH) + * ./profiler-cli/dist/profiler-cli.js (direct invocation) + */ + +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'; +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'; +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.PROFILER_CLI_SESSION_DIR || + path.join(os.homedir(), '.profiler-cli'); + +async function main(): Promise { + const rawArgs = process.argv.slice(2); + + // 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; + 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, symbolServerUrl); + return; + } + + const program = new Command(); + 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( + 'after', + ` +Examples: + 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` + ); + + // Unknown commands + program.on('command:*', (operands: string[]) => { + console.error(`Error: Unknown command '${operands[0]}'\n`); + program.outputHelp(); + process.exit(1); + }); + + // profiler-cli load + addGlobalOptions( + 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.symbolServer + ); + console.log(`Session started: ${sessionId}`); + }); + + // profiler-cli 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 + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + // profiler-cli 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)) + ); + } else { + const sessionId = idArg ?? opts.session; + await stopDaemon(SESSION_DIR, sessionId); + } + }); + + // profiler-cli guide + program + .command('guide') + .description('Show detailed usage guide (commands, patterns, tips)') + .action(() => { + console.log(guideText); + }); + + // profiler-cli 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); + registerFunctionCommand(program, SESSION_DIR); + registerZoomCommand(program, SESSION_DIR); + registerFilterCommand(program, SESSION_DIR); + registerSessionCommand(program, SESSION_DIR); + + try { + await program.parseAsync(process.argv); + } 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/profiler-cli/src/output.ts b/profiler-cli/src/output.ts new file mode 100644 index 0000000000..36d8fc6345 --- /dev/null +++ b/profiler-cli/src/output.ts @@ -0,0 +1,94 @@ +/* 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 profiler-cli commands. + */ + +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import type { CommandResult } from './protocol'; +import { + formatStatusResult, + formatFunctionExpandResult, + formatFunctionInfoResult, + formatFunctionAnnotateResult, + formatViewRangeResult, + formatFilterStackResult, + formatThreadInfoResult, + formatMarkerStackResult, + formatMarkerInfoResult, + formatProfileInfoResult, + formatThreadSamplesResult, + formatThreadSamplesTopDownResult, + formatThreadSamplesBottomUpResult, + formatThreadMarkersResult, + formatThreadFunctionsResult, + formatThreadNetworkResult, + formatProfileLogsResult, + formatThreadPageLoadResult, + formatThreadSelectResult, +} 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 'function-annotate': + return formatFunctionAnnotateResult(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); + case 'thread-network': + return formatThreadNetworkResult(result); + case 'profile-logs': + return formatProfileLogsResult(result); + case 'thread-page-load': + return formatThreadPageLoadResult(result); + case 'thread-select': + return formatThreadSelectResult(result); + default: + throw assertExhaustiveCheck(result); + } +} diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts new file mode 100644 index 0000000000..df510dff91 --- /dev/null +++ b/profiler-cli/src/protocol.ts @@ -0,0 +1,207 @@ +/* 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 profiler-cli client and daemon. + * Messages are sent as line-delimited JSON over Unix domain sockets. + */ + +// Re-export shared types from profile-query +export type { + MarkerFilterOptions, + FlatMarkerItem, + FunctionFilterOptions, + SampleFilterSpec, + FilterEntry, + FilterStackResult, + SessionContext, + WithContext, + StatusResult, + FunctionExpandResult, + FunctionInfoResult, + FunctionAnnotateResult, + AnnotateMode, + ViewRangeResult, + ThreadInfoResult, + ThreadSamplesResult, + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + CallTreeNode, + CallTreeScoringStrategy, + ThreadMarkersResult, + ThreadNetworkResult, + NetworkRequestEntry, + NetworkPhaseTimings, + ThreadFunctionsResult, + ThreadPageLoadResult, + NavigationMilestone, + PageLoadResourceEntry, + PageLoadCategoryEntry, + JankPeriod, + JankFunction, + DurationStats, + RateStats, + MarkerGroupData, + MarkerInfoResult, + MarkerStackResult, + StackTraceData, + ProfileInfoResult, + ProfileLogsResult, + ThreadSelectResult, +} from '../../src/profile-query/types'; +export type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; + +// Import types for use in type definitions +import type { + MarkerFilterOptions, + FunctionFilterOptions, + SampleFilterSpec, + WithContext, + StatusResult, + FunctionExpandResult, + FunctionInfoResult, + FunctionAnnotateResult, + AnnotateMode, + ViewRangeResult, + ThreadInfoResult, + MarkerStackResult, + MarkerInfoResult, + ProfileInfoResult, + ThreadSamplesResult, + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + ThreadMarkersResult, + ThreadNetworkResult, + ThreadFunctionsResult, + ThreadPageLoadResult, + FilterStackResult, + ProfileLogsResult, + ThreadSelectResult, +} from '../../src/profile-query/types'; +import type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; + +export type ClientMessage = + | { type: 'command'; command: ClientCommand } + | { type: 'shutdown' } + | { type: 'status' }; + +export type ClientCommand = + | { + command: 'profile'; + subcommand: 'info' | 'threads'; + all?: boolean; + search?: string; + } + | { + command: 'profile'; + subcommand: 'logs'; + logFilters?: { + thread?: string; + module?: string; + level?: string; + search?: string; + limit?: number; + }; + } + | { + command: 'thread'; + subcommand: + | 'info' + | 'select' + | 'samples' + | 'samples-top-down' + | 'samples-bottom-up' + | 'markers' + | 'functions' + | 'network' + | 'page-load'; + thread?: string; + includeIdle?: boolean; + search?: string; + markerFilters?: MarkerFilterOptions; + functionFilters?: FunctionFilterOptions; + callTreeOptions?: CallTreeCollectionOptions; + networkFilters?: { + searchString?: string; + minDuration?: number; + maxDuration?: number; + limit?: number; + }; + pageLoadOptions?: { + navigationIndex?: number; + jankLimit?: number; + }; + /** Ephemeral sample filters applied only to this command invocation */ + sampleFilters?: SampleFilterSpec[]; + } + | { + command: 'marker'; + subcommand: 'info' | 'select' | 'stack'; + marker?: string; + } + | { command: 'sample'; subcommand: 'info' | 'select'; sample?: string } + | { + command: 'function'; + 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'; + subcommand: 'push' | 'pop' | 'clear'; + range?: string; + } + | { + command: 'filter'; + subcommand: 'push' | 'pop' | 'list' | 'clear'; + thread?: string; + spec?: SampleFilterSpec; + count?: number; + } + | { command: 'status' }; + +export type ServerResponse = + | { type: 'success'; result: string | CommandResult } + | { type: 'error'; error: string } + | { type: 'loading' } + | { type: 'symbolicating' } + | { 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 + | FilterStackResult + | WithContext + | WithContext + | WithContext + | WithContext + | WithContext + | 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/profiler-cli/src/session.ts b/profiler-cli/src/session.ts new file mode 100644 index 0000000000..bb746db959 --- /dev/null +++ b/profiler-cli/src/session.ts @@ -0,0 +1,239 @@ +/* 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 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 PROFILER_CLI_SESSION_DIR + * once and passes it through. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +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 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') { + const sessionDirNamespace = getSessionDirNamespace(sessionDir); + return `\\\\.\\pipe\\profiler-cli-${sessionDirNamespace}-${sessionId}`; + } + 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'); + + try { + return fs.readFileSync(currentSessionFile, 'utf-8').trim(); + } catch (error: any) { + if (error && error.code === 'ENOENT') { + return null; + } + throw error; + } +} + +/** + * 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; + } +} + +/** + * 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. + */ +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 (Unix only — named pipes on Windows are not filesystem files) + // 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 + fs.rmSync(metadataPath, { force: true }); + + // Remove current session file if it points to this session + const currentSessionId = getCurrentSessionId(sessionDir); + if (currentSessionId === sessionId) { + fs.rmSync(currentSessionFile, { force: true }); + } +} + +/** + * 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)) { + return null; + } + + // Check if socket exists (Unix only — named pipes on Windows are not filesystem files) + if (process.platform !== 'win32' && !fs.existsSync(metadata.socketPath)) { + 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/profiler-cli/src/utils/parse.ts b/profiler-cli/src/utils/parse.ts new file mode 100644 index 0000000000..93a4c17260 --- /dev/null +++ b/profiler-cli/src/utils/parse.ts @@ -0,0 +1,207 @@ +/* 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 profiler-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[]; + merge?: string[]; + rootAt?: string[]; + includesFunction?: 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.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.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 `profiler-cli filter push`. + * Exactly one filter flag must be provided. + */ +export function parseFilterSpec(opts: EphemeralFilterOpts): SampleFilterSpec { + const valueFlags = [ + 'excludesFunction', + 'merge', + 'rootAt', + 'includesFunction', + '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', + '--merge', + '--root-at', + '--includes-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': + 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': + 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/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/scripts/build-profiler-cli.mjs b/scripts/build-profiler-cli.mjs new file mode 100644 index 0000000000..4cff0ef1d9 --- /dev/null +++ b/scripts/build-profiler-cli.mjs @@ -0,0 +1,36 @@ +/* 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 { 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 = { + ...nodeBaseConfig, + entryPoints: ['profiler-cli/src/index.ts'], + loader: { ...nodeBaseConfig.loader, '.txt': 'text' }, + 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;', + }, + define: { + __BUILD_HASH__: JSON.stringify(BUILD_HASH), + __VERSION__: JSON.stringify(version), + }, + external: [...nodeBaseConfig.external, 'gecko-profiler-demangle'], +}; + +async function build() { + 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/scripts/publish-profiler-cli.mjs b/scripts/publish-profiler-cli.mjs new file mode 100644 index 0000000000..b832776aa3 --- /dev/null +++ b/scripts/publish-profiler-cli.mjs @@ -0,0 +1,32 @@ +/* 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 { readFileSync } from 'fs'; +import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const pkgUrl = new URL('../profiler-cli/package.json', import.meta.url); +const { version } = JSON.parse(readFileSync(pkgUrl, 'utf8')); + +const forwardedArgs = process.argv.slice(2); +const userSpecifiedTag = forwardedArgs.some( + (a) => a === '--tag' || a.startsWith('--tag=') +); +const isPrerelease = version.includes('-'); +// TODO: switch 'alpha' to 'next' once a stable release exists and we want the +// conventional pre-release channel. +const tagArgs = userSpecifiedTag + ? [] + : ['--tag', isPrerelease ? 'alpha' : 'latest']; + +function run(cmd, args) { + const result = spawnSync(cmd, args, { cwd: repoRoot, stdio: 'inherit' }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +run('yarn', ['test-all']); +run('yarn', ['build-profiler-cli']); +run('npm', ['publish', 'profiler-cli/', ...tagArgs, ...forwardedArgs]); diff --git a/scripts/verify-profiler-cli-build.mjs b/scripts/verify-profiler-cli-build.mjs new file mode 100644 index 0000000000..cd9fe40460 --- /dev/null +++ b/scripts/verify-profiler-cli-build.mjs @@ -0,0 +1,34 @@ +/* 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 { existsSync, readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; + +const pkgUrl = new URL('../profiler-cli/package.json', import.meta.url); +const distUrl = new URL( + '../profiler-cli/dist/profiler-cli.js', + import.meta.url +); +const distPath = fileURLToPath(distUrl); + +if (!existsSync(distUrl)) { + console.error( + `profiler-cli bundle not found at ${distPath}.\n` + + `Run 'yarn build-profiler-cli' from the repo root before publishing.` + ); + process.exit(1); +} + +const { version } = JSON.parse(readFileSync(pkgUrl, 'utf8')); +const bundle = readFileSync(distUrl, 'utf8'); +const needle = JSON.stringify(version); + +if (!bundle.includes(needle)) { + console.error( + `profiler-cli bundle does not embed the current package.json version (${version}).\n` + + `The bundle is stale — rebuild with 'yarn build-profiler-cli' from the repo root.` + ); + process.exit(1); +} + +console.log(`✅ profiler-cli build verified (version ${version})`); 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/README.md b/src/profile-query/README.md new file mode 100644 index 0000000000..46463dd553 --- /dev/null +++ b/src/profile-query/README.md @@ -0,0 +1,50 @@ +# Profile Query Library + +A library for programmatically querying the contents of a Firefox Profiler profile. + +## Usage + +### 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(); +``` + +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. + +## 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 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 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/cpu-activity.ts b/src/profile-query/cpu-activity.ts new file mode 100644 index 0000000000..79351ac47b --- /dev/null +++ b/src/profile-query/cpu-activity.ts @@ -0,0 +1,118 @@ +import type { Slice, SliceTree } from 'firefox-profiler/utils/slice-tree'; +import type { TimestampManager } from './timestamps'; + +export interface CpuActivityEntry { + startTime: number; + startTimeName: string; + startTimeStr: string; + endTime: number; + endTimeName: string; + endTimeStr: string; + cpuMs: number; + depthLevel: number; +} + +/** + * Collect CPU activity slices as structured data. + */ +export function collectSliceTree( + { slices, time }: SliceTree, + tsManager: TimestampManager +): CpuActivityEntry[] { + if (slices.length === 0) { + return []; + } + + const childrenStartPerParent: Array = new Array(slices.length); + const indexAndSumPerSlice: Array<{ i: number; sum: number }> = []; + 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 result: CpuActivityEntry[] = []; + collectSliceSubtree( + slices, + 0, + null, + childrenStartPerParent, + interestingSliceIndexes, + 0, + time, + result, + tsManager + ); + + return result; +} + +function collectSliceSubtree( + slices: Slice[], + startIndex: number, + parent: number | null, + childrenStartPerParent: Array, + 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/filter-stack.ts b/src/profile-query/filter-stack.ts new file mode 100644 index 0000000000..668f1f89c3 --- /dev/null +++ b/src/profile-query/filter-stack.ts @@ -0,0 +1,223 @@ +/* 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/. */ + +/** + * Filter helpers for profiler-cli. + * + * The CLI's filter stack is just the Redux transform stack for a thread, so + * there is no separate in-memory representation to track. This module provides + * two helpers: + * + * - `pushSpecTransforms` — dispatches the Redux transforms that implement a + * SampleFilterSpec (the CLI's `filter push` DSL). + * - `describeSpec` / `describeTransform` — human-readable descriptions. Specs + * come from `filter push`; transforms come from the raw Redux stack (which + * can include URL-loaded entries that weren't pushed by the CLI). + */ + +import { addTransformToStack } from '../actions/profile-view'; +import type { SampleFilterSpec } from './types'; +import type { Store } from '../types/store'; +import type { ThreadsKey, Transform } from 'firefox-profiler/types'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/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 assertExhaustiveCheck(spec); + } +} + +/** + * Build a human-readable description for a single Redux transform. + * + * Where a transform corresponds 1:1 to a CLI filter spec (e.g. `drop-function` + * is what `--excludes-function` pushes), use the CLI wording so `filter list` + * matches what the user typed. For transforms the CLI never produces — only + * URL-loaded or web-app-produced ones — fall back to the transform type. + */ +function describeSingleTransform(transform: Transform): string { + switch (transform.type) { + // CLI-produced transforms: use the original filter-spec wording. + case 'drop-function': + return `excludes function: f-${transform.funcIndex}`; + case 'merge-function': + return `merge: f-${transform.funcIndex}`; + case 'focus-function': + return `root-at: f-${transform.funcIndex}`; + case 'filter-samples': + switch (transform.filterType) { + case 'marker-search': + return `during marker matching: "${transform.filter}"`; + case 'outside-marker': + return `outside marker matching: "${transform.filter}"`; + case 'function-include': + return `includes function: f-${transform.filter.split(',').join(', f-')}`; + case 'stack-prefix': + return `includes prefix: f-${transform.filter.split(',').join(' → f-')}`; + case 'stack-suffix': + return `includes suffix: f-${transform.filter}`; + default: + return `filter-samples (${transform.filterType}): "${transform.filter}"`; + } + + // URL-only / web-app transforms: generic description. + case 'focus-subtree': + return `focus-subtree: ${transform.callNodePath.map((f) => `f-${f}`).join(' → ')}${transform.inverted ? ' (inverted)' : ''}`; + case 'focus-self': + return `focus-self: f-${transform.funcIndex}`; + case 'merge-call-node': + return `merge-call-node: ${transform.callNodePath.map((f) => `f-${f}`).join(' → ')}`; + case 'collapse-resource': + return `collapse-resource: r-${transform.resourceIndex}`; + case 'collapse-direct-recursion': + return `collapse-direct-recursion: f-${transform.funcIndex}`; + case 'collapse-recursion': + return `collapse-recursion: f-${transform.funcIndex}`; + case 'collapse-function-subtree': + return `collapse-function-subtree: f-${transform.funcIndex}`; + case 'focus-category': + return `focus-category: ${transform.category}`; + default: + throw assertExhaustiveCheck(transform); + } +} + +/** + * Build a human-readable description for a filter-stack entry, which may back + * one or more Redux transforms. Multi-transform groups come from CLI specs + * that accept a comma-separated list (e.g. `--merge f-1,f-2` dispatches two + * merge-function transforms); render them with the spec's plural wording so + * the display matches what the user typed. + */ +export function describeTransformGroup(transforms: Transform[]): string { + if (transforms.length === 1) { + return describeSingleTransform(transforms[0]); + } + const allSameType = transforms.every((t) => t.type === transforms[0].type); + if (allSameType && transforms[0].type === 'drop-function') { + const ids = (transforms as { funcIndex: number }[]) + .map((t) => `f-${t.funcIndex}`) + .join(', '); + return `excludes function: ${ids}`; + } + if (allSameType && transforms[0].type === 'merge-function') { + const ids = (transforms as { funcIndex: number }[]) + .map((t) => `f-${t.funcIndex}`) + .join(', '); + return `merge: ${ids}`; + } + // Shouldn't happen in practice — multi-transform groups only come from the + // two CLI specs above. Join single descriptions as a last resort. + return transforms.map(describeSingleTransform).join('; '); +} + +/** + * Dispatch the Redux transforms for a filter spec and return the number pushed. + * Used by `filter push` (sticky) and by ephemeral-filter application. + */ +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 assertExhaustiveCheck(spec); + } +} diff --git a/src/profile-query/formatters/call-tree.ts b/src/profile-query/formatters/call-tree.ts new file mode 100644 index 0000000000..18353efd98 --- /dev/null +++ b/src/profile-query/formatters/call-tree.ts @@ -0,0 +1,326 @@ +/* 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, Lib } from 'firefox-profiler/types'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import type { CallTreeNode, CallTreeScoringStrategy } from '../types'; +import { getFunctionHandle } 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: + throw assertExhaustiveCheck(strategy); + } +} + +/** + * 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, + 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, libs); +} + +/** + * Build tree structure from the set of included nodes. + */ +function buildTreeStructure( + tree: CallTree, + includedNodes: 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: getFunctionHandle(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 NOT 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..e013493f79 --- /dev/null +++ b/src/profile-query/formatters/marker-info.ts @@ -0,0 +1,1442 @@ +/* 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 { changeMarkersSearchString } from '../../actions/profile-view'; +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, + MarkerSchemaByName, +} from 'firefox-profiler/types'; +import type { StringTable } from 'firefox-profiler/utils/string-table'; +import type { + MarkerStackResult, + MarkerInfoResult, + StackTraceData, + ThreadMarkersResult, + ThreadNetworkResult, + NetworkRequestEntry, + NetworkPhaseTimings, + MarkerGroupData, + DurationStats, + RateStats, + MarkerFilterOptions, + FlatMarkerItem, + ProfileLogsResult, +} from '../types'; +import { + isNetworkMarker, + LOG_LEVEL_STRING_TO_LETTER, + LOG_LETTER_TO_LEVEL, + formatLogTimestamp, + formatLogStatement, +} from 'firefox-profiler/profile-logic/marker-data'; +import { formatFunctionNameWithLibrary } from '../function-list'; +import type { + NetworkPayload, + LogMarkerPayload, +} from 'firefox-profiler/types/markers'; + +/** + * Aggregated statistics for a group of markers. + */ +interface MarkerNameStats { + 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), + }; +} + +/** + * 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 } = 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; + }); + } + + 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; +}> { + // Partition into interval (sortable by duration) and instant markers, so + // null `end` values never reach the comparator and produce NaN. + const intervalItems = items.filter((item) => item.marker.end !== null); + const instantItems = items.filter((item) => item.marker.end === null); + intervalItems.sort( + (a, b) => b.marker.end! - b.marker.start - (a.marker.end! - a.marker.start) + ); + const sortedItems = [...intervalItems, ...instantItems]; + + 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, + markerSchemaByName: MarkerSchemaByName, + stringTable: StringTable +): 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)'; + } + // For fields whose format stores a string-table index (unique-string / + // flow-id / terminating-flow-id), resolve to the interned string so groups + // show "Error" / "click" / ... instead of integer indices. + const schema = marker.data ? markerSchemaByName[marker.data.type] : undefined; + const field = schema?.fields.find((f) => f.key === key.field); + if ( + field && + (field.format === 'unique-string' || + field.format === 'flow-id' || + field.format === 'terminating-flow-id') && + typeof fieldValue === 'number' + ) { + return stringTable.getString(fieldValue, '(empty)'); + } + 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. + * + * Schema-driven: iterates the marker schema's declared fields rather than + * probing the first marker's `Object.keys`, so fields absent from the first + * marker still get considered. For fields whose format stores a string-table + * index (`unique-string` / `flow-id` / `terminating-flow-id`), we resolve the + * interned string before computing cardinality — otherwise we'd see variance + * over integer indices. + * + * The schema's `format` tells us which fields are enum-like candidates. High- + * cardinality formats (url, file-path, any time/byte/percent/decimal, list, + * table) are skipped outright; ID-shaped key-name heuristics are unnecessary. + * + * Scoring: + * - 3-20 unique values is the ideal range (score 100), decaying up to 50 + * - Skip fields that appear in < 80% of markers, or with < 3 unique values + * - Small boost if the field is present on every marker + */ +function analyzeFieldVariance( + markers: Marker[], + markerSchemaByName: MarkerSchemaByName, + stringTable: StringTable +): { field: string; variance: number } | null { + if (markers.length === 0) { + return null; + } + const schemaName = markers[0].data?.type; + if (!schemaName) { + return null; + } + const schema = markerSchemaByName[schemaName]; + if (!schema) { + return null; + } + + const fieldScores: Array<{ + field: string; + score: number; + uniqueCount: number; + }> = []; + + for (const fieldSchema of schema.fields) { + if (fieldSchema.hidden) { + continue; + } + const fmt = fieldSchema.format; + // Only enum-like formats are useful for auto-grouping. Everything else — + // urls, file paths, any numeric quantity (bytes/time/percent/decimal), + // flow-ids (unique-per-flow by construction), lists, tables — would + // produce either a useless single-value grouping or an ID-like blowup. + const isEnumLike = + fmt === 'string' || + fmt === 'unique-string' || + fmt === 'integer' || + fmt === 'pid' || + fmt === 'tid'; + if (!isEnumLike) { + continue; + } + const needsStringTable = fmt === 'unique-string'; + + const uniqueValues = new Set(); + let validCount = 0; + + for (const marker of markers) { + const raw = (marker.data as any)?.[fieldSchema.key]; + if (raw === undefined || raw === null) { + continue; + } + const resolved = + needsStringTable && typeof raw === 'number' + ? stringTable.getString(raw, '') + : String(raw); + uniqueValues.add(resolved); + validCount++; + } + + if (validCount < markers.length * 0.8) { + continue; + } + const uniqueCount = uniqueValues.size; + if (uniqueCount < 3) { + continue; + } + + let score = 0; + if (uniqueCount <= 20) { + score = 100; + } else if (uniqueCount <= 50) { + score = 100 - (uniqueCount - 20) * 2; + } else { + score = 10; + } + if (validCount === markers.length) { + score += 10; + } + + fieldScores.push({ field: fieldSchema.key, score, uniqueCount }); + } + + if (fieldScores.length === 0) { + return null; + } + + fieldScores.sort((a, b) => b.score - a.score); + return { field: fieldScores[0].field, variance: fieldScores[0].score / 100 }; +} + +/** + * 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, + markerSchemaByName: MarkerSchemaByName, + stringTable: StringTable, + threadIndexes: Set, + markerMap: MarkerMap, + getMarkerLabel: (markerIndex: MarkerIndex) => string, + depth: number = 0, + maxTopMarkers: number = 5 +): 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, + markerSchemaByName, + stringTable + ); + 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, + maxTopMarkers + ); + + // Recursively group by remaining keys (limit depth to 3) + const subGroups = + remainingKeys.length > 0 && depth < 2 + ? groupMarkers( + items, + remainingKeys, + categories, + markerSchemaByName, + stringTable, + threadIndexes, + markerMap, + getMarkerLabel, + depth + 1, + maxTopMarkers + ) + : 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 `marker.name` (not by `marker.data.type` — these differ + * when a marker with the same payload type is emitted under different names, + * or when a marker has no payload at all). The output is surfaced as `byType` + * in the JSON schema for historical reasons; callers wanting to group by the + * schema type should use `--group-by type`. + * + * Optionally applies auto-grouping or custom grouping. + */ +function aggregateMarkersByName( + markers: Marker[], + markerIndexes: MarkerIndex[], + threadIndexes: Set, + markerMap: MarkerMap, + getMarkerLabel: (markerIndex: MarkerIndex) => string, + categories: CategoryList, + markerSchemaByName: MarkerSchemaByName, + stringTable: StringTable, + autoGroup: boolean = false, + maxTopMarkers: number = 5 +): MarkerNameStats[] { + // 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: MarkerNameStats[] = []; + + 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 N markers by duration (or just first N for instant markers) + const topMarkers = createTopMarkersArray( + markerGroup, + threadIndexes, + markerMap, + getMarkerLabel, + maxTopMarkers + ); + + // Apply auto-grouping if enabled + let subGroups: MarkerGroup[] | undefined; + let subGroupKey: string | undefined; + if (autoGroup && markerList.length > 5) { + const fieldInfo = analyzeFieldVariance( + markerList, + markerSchemaByName, + stringTable + ); + if (fieldInfo) { + // Sub-group by the field with highest variance + subGroups = groupMarkers( + markerGroup, + [{ field: fieldInfo.field }], + categories, + markerSchemaByName, + stringTable, + threadIndexes, + markerMap, + getMarkerLabel, + 1, + maxTopMarkers + ); + 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. Keyed on the raw category index so that two + * categories sharing a name stay separate in the output, and so callers don't + * need an O(n) findIndex lookup to recover the index by name. + */ +function aggregateMarkersByCategory( + markers: Marker[], + markerIndexes: MarkerIndex[], + categories: CategoryList +): Array<{ + categoryIndex: number; + categoryName: string; + count: number; + percentage: number; +}> { + const counts = new Map(); + + for (const markerIndex of markerIndexes) { + const marker = markers[markerIndex]; + counts.set(marker.category, (counts.get(marker.category) ?? 0) + 1); + } + + const total = markerIndexes.length; + return Array.from(counts.entries()) + .map(([categoryIndex, count]) => ({ + categoryIndex, + categoryName: categories[categoryIndex]?.name ?? 'Unknown', + count, + percentage: (count / total) * 100, + })) + .sort((a, b) => b.count - a.count); +} + +/** + * 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, topN } = filterOptions; + const maxTopMarkers = topN ?? 5; + + // 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, + markerSchemaByName, + stringTable, + threadIndexes, + markerMap, + getMarkerLabel, + 0, + maxTopMarkers + ); + + // Add markerIndex to topMarkers in groups + customGroups = addMarkerIndexToGroups(groups); + } + + // Aggregate by type (with optional auto-grouping) + const nameStats = aggregateMarkersByName( + fullMarkerList, + filteredIndexes, + threadIndexes, + markerMap, + getMarkerLabel, + categories, + markerSchemaByName, + stringTable, + autoGroup || false, + maxTopMarkers + ); + + // Convert nameStats to include markerIndex + const byType = nameStats.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: stats.categoryIndex, + 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; + + let flatMarkers: FlatMarkerItem[] | undefined; + if (filterOptions.list) { + flatMarkers = []; + const listIndexes = + limit !== undefined ? filteredIndexes.slice(0, limit) : filteredIndexes; + for (const markerIndex of listIndexes) { + 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, + friendlyThreadName, + totalMarkerCount: originalCount, + filteredMarkerCount: filteredIndexes.length, + filters, + byType, + byCategory, + customGroups, + flatMarkers, + }; + } 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, + })); +} + +/** + * 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'] = []; + + let currentStackIndex: IndexIntoStackTable | null = stackIndex; + while (currentStackIndex !== null) { + const frameIndex = stackTable.frame[currentStackIndex]; + const funcIndex = frameTable.func[frameIndex]; + const funcName = stringTable.getString(funcTable.name[funcIndex]); + const nameWithLibrary = formatFunctionNameWithLibrary( + funcIndex, + thread, + libs + ); + + let library: string | undefined; + const resourceIndex = funcTable.resource[funcIndex]; + if (resourceIndex !== -1) { + const libIndex = resourceTable.lib[resourceIndex]; + if (libIndex !== null && libs) { + library = libs[libIndex].name; + } + } + + 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, + }; +} + +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, + }; +} + +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/formatters/page-load.ts b/src/profile-query/formatters/page-load.ts new file mode 100644 index 0000000000..d2eeb46b03 --- /dev/null +++ b/src/profile-query/formatters/page-load.ts @@ -0,0 +1,503 @@ +/* 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 rawJankLimit = options.jankLimit ?? 10; + const jankLimit = rawJankLimit === 0 ? Infinity : rawJankLimit; + + 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/formatters/profile-info.ts b/src/profile-query/formatters/profile-info.ts new file mode 100644 index 0000000000..1cce4668d4 --- /dev/null +++ b/src/profile-query/formatters/profile-info.ts @@ -0,0 +1,169 @@ +/* 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, 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. + */ +export function collectProfileInfo( + store: Store, + timestampManager: TimestampManager, + threadMap: ThreadMap, + processIndexMap: Map, + showAll: boolean = false, + search?: string +): 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, + tid: thread.tid, + cpuMs: threadCPUTimeMs ? threadCPUTimeMs[index] : 0, + pid: thread.pid, + })); + + // Build the process/thread list (always show all when searching) + const result = buildProcessThreadList( + threads, + processIndexMap, + showAll || search !== undefined + ); + + // Apply process names, eTLD+1, and timing from the profile + result.processes.forEach((processItem) => { + const threadFromProcess = profile.threads.find( + (t) => t.pid === processItem.pid + ); + if (threadFromProcess) { + processItem.name = + threadFromProcess.processName || + threadFromProcess.processType || + 'unknown'; + processItem.etld1 = threadFromProcess['eTLD+1']; + processItem.startTime = threadFromProcess.processStartupTime; + processItem.endTime = threadFromProcess.processShutdownTime; + } + }); + + // 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 endTimeName: string | null | undefined; + 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: processItem.name, + etld1: processItem.etld1, + cpuMs: processItem.cpuMs, + startTime: processItem.startTime, + startTimeName, + 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, + }; + } + ); + + // 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, + showAll: showAll && search === undefined, + searchQuery: search, + processes: processesData, + remainingProcesses: + search !== undefined ? undefined : 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..4c3bc484f9 --- /dev/null +++ b/src/profile-query/formatters/thread-info.ts @@ -0,0 +1,456 @@ +/* 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 { 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 { getFunctionHandle } from '../function-map'; +import type { CallNodePath } from 'firefox-profiler/types'; + +/** + * 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, + tid: thread.tid, + 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, + 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: getFunctionHandle(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, + })); + + const topFunctionsBySelf: TopFunctionInfo[] = sortedBySelf.map((func) => ({ + functionHandle: getFunctionHandle(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, + })); + + // 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) { + 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; + 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, + 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 + 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 + ); + + const libs = getProfile(state).libs; + const invertedCallTree = collectCallTree(invertedTree, libs, callTreeOptions); + + 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, + 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, 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, + 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) { + // Use the same weighting as the call tree: sum weights, exclude null-stack samples + const rawThread = threadSelectors.getRawThread(state); + const { weight, stack } = rawThread.samples; + let total = 0; + for (let i = 0; i < rawThread.samples.length; i++) { + if (stack[i] !== null) { + total += weight ? (weight[i] ?? 1) : 1; + } + } + fullProfileTotalSamples = total; + } + + // 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: getFunctionHandle(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-annotate.ts b/src/profile-query/function-annotate.ts new file mode 100644 index 0000000000..ad0b205368 --- /dev/null +++ b/src/profile-query/function-annotate.ts @@ -0,0 +1,394 @@ +/* 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 } from 'firefox-profiler/selectors/profile'; +import { getSelectedThreadIndexes } from 'firefox-profiler/selectors/url-state'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { parseFunctionHandle } from './function-map'; +import type { ThreadMap } from './thread-map'; +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 } from 'firefox-profiler/types'; +import type { + FunctionAnnotateResult, + AnnotateMode, + FunctionSourceAnnotation, + FunctionAsmAnnotation, +} from './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 async function functionAnnotate( + store: Store, + threadMap: ThreadMap, + archiveCache: Map>, + functionHandle: string, + mode: AnnotateMode, + symbolServerUrl: string, + contextOption: string +): Promise { + const state = 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 = threadMap.handleForThreadIndexes(threadIndexes); + + // Single pass over frameTable to collect everything keyed on funcIndex: + // - frameInFunc: which frames belong to funcIndex + // - nativeSymbolsForFunc: distinct native symbols for this func + // - addressProof: first usable {debugName, breakpadId, address} for /source/v1 + const frameInFunc = new Uint8Array(frameTable.func.length); + const nativeSymbolsForFunc = new Set(); + let addressProof: AddressProof | null = null; + for (let fi = 0; fi < frameTable.func.length; fi++) { + if (frameTable.func[fi] !== funcIndex) { + continue; + } + frameInFunc[fi] = 1; + const ns = frameTable.nativeSymbol[fi]; + if (ns !== null) { + nativeSymbolsForFunc.add(ns); + if (addressProof === 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], + }; + } + } + } + } + // 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; + } + } + + // addressProof is built in the single frameTable pass above; it's used + // by fetchSource to query /source/v1 on local symbol servers. + + // 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, + 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') { + if (nativeSymbolsForFunc.size === 0) { + warnings.push( + `Function ${functionHandle} has no native symbols — may be JS-only or not symbolicated.` + ); + } + + const nativeSymbolCount = nativeSymbolsForFunc.size; + + // Fan out fetchAssembly in parallel — each native symbol is an + // independent symbol-server request. + const results = await Promise.all( + Array.from(nativeSymbolsForFunc).map(async (nsIndex) => { + 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]; + + 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'] = []; + const localWarnings: string[] = []; + + 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('; '); + localWarnings.push( + `Assembly fetch failed for ${symbolName}: ${fetchError}` + ); + } + } catch (e) { + fetchError = e instanceof Error ? e.message : String(e); + localWarnings.push( + `Assembly fetch threw for ${symbolName}: ${fetchError}` + ); + } + + return { + symbolName, + symbolAddress, + functionSize, + fetchError, + instructions, + localWarnings, + }; + }) + ); + + results.forEach((r, i) => { + warnings.push(...r.localWarnings); + asmAnnotations.push({ + compilationIndex: i + 1, + symbolName: r.symbolName, + symbolAddress: r.symbolAddress, + functionSize: r.functionSize, + nativeSymbolCount, + fetchError: r.fetchError, + instructions: r.instructions, + }); + }); + } + + return { + type: 'function-annotate', + functionHandle, + funcIndex, + name: funcName, + fullName, + threadHandle, + friendlyThreadName, + totalSelfSamples, + totalTotalSamples, + mode, + srcAnnotation, + asmAnnotations, + warnings, + }; +} diff --git a/src/profile-query/function-list.ts b/src/profile-query/function-list.ts new file mode 100644 index 0000000000..ebb5edca97 --- /dev/null +++ b/src/profile-query/function-list.ts @@ -0,0 +1,533 @@ +/* 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, + FuncTable, + ResourceTable, +} from 'firefox-profiler/types'; +import { getFunctionHandle } from './function-map'; + +/** + * Look up the Lib record for a function, or undefined if none is associated. + */ +export function getLibForFunc( + funcIndex: number, + funcTable: FuncTable, + resourceTable: ResourceTable, + libs: Lib[] +): Lib | undefined { + const resourceIndex = funcTable.resource[funcIndex]; + if (resourceIndex === -1) { + return undefined; + } + const libIndex = resourceTable.lib[resourceIndex]; + if (libIndex !== null && libIndex !== undefined && libIndex >= 0) { + return libs[libIndex]; + } + return undefined; +} + +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 === ')') { + if (stack.length > 1) { + flushText(); + stack.pop(); + } else { + // Unmatched closing bracket (e.g. operator>>, operator>>) — treat as text + currentText += char; + } + } 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 lib = getLibForFunc( + funcIndex, + thread.funcTable, + thread.resourceTable, + libs + ); + if (lib) { + return `${lib.name}!${funcName}`; + } + // Fall back to resource name if no library + const resourceIndex = thread.funcTable.resource[funcIndex]; + if (resourceIndex !== -1) { + const resourceName = thread.stringTable.getString( + thread.resourceTable.name[resourceIndex] + ); + 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' +): 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); + + const handle = getFunctionHandle(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' +): FormattedFunctionList { + const displayedFunctions = functions.slice(0, limit); + const omittedFunctions = functions.slice(limit); + + const lines = displayedFunctions.map((func) => + formatFunctionEntry(func, sortKey) + ); + + 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 +): { byTotal: FormattedFunctionList; bySelf: FormattedFunctionList } { + const byTotal = formatFunctionList( + 'Top Functions (by total time)', + sortByTotal(functions), + limit, + 'total' + ); + + const bySelf = formatFunctionList( + 'Top Functions (by self time)', + sortBySelf(functions), + limit, + 'self' + ); + + 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..0b48685a92 --- /dev/null +++ b/src/profile-query/function-map.ts @@ -0,0 +1,35 @@ +/* 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 { IndexIntoFuncTable } from 'firefox-profiler/types'; + +/** + * 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 function getFunctionHandle( + funcIndex: IndexIntoFuncTable +): `f-${number}` { + return `f-${funcIndex}`; +} + +/** + * Parse a function handle and validate it against the shared funcTable length. + */ +export function parseFunctionHandle( + functionHandle: string, + funcCount: number +): IndexIntoFuncTable { + const match = /^f-(\d+)$/.exec(functionHandle); + if (match === null) { + throw new Error(`Unknown function ${functionHandle}`); + } + + 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 new file mode 100644 index 0000000000..55ad2cb4b1 --- /dev/null +++ b/src/profile-query/index.ts @@ -0,0 +1,1161 @@ +/* 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, + getIncludeIdleSamples, + getSelectedThreadIndexes, + getTransformStack, + getCurrentSearchString, + getProfileSpecificState, +} from 'firefox-profiler/selectors/url-state'; +import { + commitRange, + popCommittedRanges, + changeSelectedThreads, + changeCallTreeSearchString, + changeIncludeIdleSamples, + popTransformsFromStackForThreads, +} from '../actions/profile-view'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { TimestampManager } from './timestamps'; +import { ThreadMap } from './thread-map'; +import { parseFunctionHandle } from './function-map'; +import { getLibForFunc } from './function-list'; +import { MarkerMap } from './marker-map'; +import { loadProfileFromFileOrUrl, type LoadOptions } from './loader'; +import { collectProfileInfo } from './formatters/profile-info'; +import { + collectThreadInfo, + collectThreadSamples, + collectThreadSamplesTopDown, + collectThreadSamplesBottomUp, + collectThreadFunctions, +} from './formatters/thread-info'; +import { + collectThreadMarkers, + collectThreadNetwork, + collectMarkerStack, + collectMarkerInfo, + collectProfileLogs, +} from './formatters/marker-info'; +import { collectThreadPageLoad } from './formatters/page-load'; +import { parseTimeValue } from './time-range-parser'; +import { describeTransformGroup, pushSpecTransforms } from './filter-stack'; +import { functionAnnotate as computeFunctionAnnotate } from './function-annotate'; +import type { + StartEndRange, + ThreadIndex, + ThreadsKey, +} from 'firefox-profiler/types'; +import type { + StatusResult, + SessionContext, + WithContext, + FunctionExpandResult, + FunctionInfoResult, + FunctionAnnotateResult, + AnnotateMode, + ViewRangeResult, + ThreadSelectResult, + ThreadInfoResult, + MarkerStackResult, + MarkerInfoResult, + ProfileInfoResult, + ThreadSamplesResult, + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + ThreadMarkersResult, + ThreadNetworkResult, + ThreadFunctionsResult, + ThreadPageLoadResult, + ProfileLogsResult, + MarkerFilterOptions, + FunctionFilterOptions, + SampleFilterSpec, + FilterStackResult, + FilterEntry, +} from './types'; +import type { CallTreeCollectionOptions } from './formatters/call-tree'; + +import { getThreadsKey } from 'firefox-profiler/profile-logic/profile-data'; +import type { Store } from '../types/store'; + +export class ProfileQuerier { + _store: Store; + _processIndexMap: Map; + _timestampManager: TimestampManager; + _threadMap: ThreadMap; + _markerMap: MarkerMap; + _archiveCache: Map>; + /** + * Per-thread sizes of each filter "push group". One `filter push` adds one + * entry whose value is the number of Redux transforms it dispatched (e.g. + * `--merge f-1,f-2` -> 2). `filter pop N` removes N groups, popping the + * matching count of Redux transforms. Transforms that were already in the + * Redux stack when the querier was constructed (URL-loaded, etc.) are + * seeded as individual size-1 groups so they remain poppable. + * + * FIXME: Add a MergeSet transform so multiple functions can be merged in a + * single group entry rather than dispatching one transform per function. + */ + _pushGroupSizes: Map; + + constructor(store: Store, rootRange: StartEndRange) { + this._store = store; + this._processIndexMap = new Map(); + this._timestampManager = new TimestampManager(rootRange); + this._threadMap = new ThreadMap(); + this._archiveCache = new Map(); + this._pushGroupSizes = new Map(); + + // 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); + }); + + // Seed thread handles eagerly so they are available immediately after load. + profile.threads.forEach((_, index) => { + this._threadMap.handleForThreadIndex(index); + }); + + // Seed push-group sizes from any transforms already in the Redux stack + // (typically loaded from a profiler.firefox.com URL). Each such transform + // becomes its own size-1 group so it remains individually poppable. + const transformsPerThread = getProfileSpecificState(state).transforms; + for (const [rawKey, stack] of Object.entries(transformsPerThread)) { + if (stack.length === 0) { + continue; + } + const threadsKey: ThreadsKey = /^-?\d+$/.test(rawKey) + ? Number(rawKey) + : rawKey; + this._pushGroupSizes.set( + threadsKey, + Array.from({ length: stack.length }, () => 1) + ); + } + } + + /** + * Ensure `_pushGroupSizes[threadsKey]` matches the Redux stack length by + * prepending size-1 groups for any transforms we haven't accounted for. + * Guards against external stack mutations between operations. + */ + private _syncPushGroups(threadsKey: ThreadsKey): number[] { + let groups = this._pushGroupSizes.get(threadsKey); + if (groups === undefined) { + groups = []; + this._pushGroupSizes.set(threadsKey, groups); + } + const stackLength = getTransformStack( + this._store.getState(), + threadsKey + ).length; + const sum = groups.reduce((a, b) => a + b, 0); + if (sum < stackLength) { + for (let i = 0; i < stackLength - sum; i++) { + groups.unshift(1); + } + } + return groups; + } + + static async load( + filePathOrUrl: string, + options: LoadOptions = {} + ): Promise { + const { store, rootRange } = await loadProfileFromFileOrUrl( + filePathOrUrl, + options + ); + return new ProfileQuerier(store, rootRange); + } + + async profileInfo( + showAll: boolean = false, + search?: string + ): Promise> { + const result = await collectProfileInfo( + this._store, + this._timestampManager, + this._threadMap, + this._processIndexMap, + showAll, + search + ); + 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, + includeIdle: boolean = false, + search?: string, + sampleFilters?: SampleFilterSpec[] + ): Promise> { + return this._runWithSampleFilters( + threadHandle, + includeIdle, + search, + sampleFilters, + () => collectThreadSamples(this._store, this._threadMap, threadHandle) + ); + } + + async threadSamplesTopDown( + threadHandle?: string, + callTreeOptions?: CallTreeCollectionOptions, + includeIdle: boolean = false, + search?: string, + sampleFilters?: SampleFilterSpec[] + ): Promise> { + return this._runWithSampleFilters( + threadHandle, + includeIdle, + search, + sampleFilters, + () => + collectThreadSamplesTopDown( + this._store, + this._threadMap, + threadHandle, + callTreeOptions + ) + ); + } + + async threadSamplesBottomUp( + threadHandle?: string, + callTreeOptions?: CallTreeCollectionOptions, + includeIdle: boolean = false, + search?: string, + sampleFilters?: SampleFilterSpec[] + ): Promise> { + return this._runWithSampleFilters( + threadHandle, + includeIdle, + search, + sampleFilters, + () => + collectThreadSamplesBottomUp( + this._store, + this._threadMap, + threadHandle, + callTreeOptions + ) + ); + } + + /** + * 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; + })(); + } + + // 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); + + // 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, + warning, + }; + } + + /** + * 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) { + const rootRange = getProfileRootRange(state); + const startName = this._timestampManager.nameForTimestamp( + rootRange.start + ); + const endName = this._timestampManager.nameForTimestamp(rootRange.end); + return { + type: 'view-range', + action: 'pop', + range: { + start: rootRange.start, + startName, + end: rootRange.end, + endName, + }, + message: `Already at full profile view: ${startName} (${this._timestampManager.timestampString(rootRange.start)}) to ${endName} (${this._timestampManager.timestampString(rootRange.end)})`, + }; + } + + // 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); + const threadNames = Array.from(threadIndexes).map( + (idx) => profile.threads[idx].name + ); + + return { + type: 'thread-select', + threadHandle, + threadNames, + context: this._getContext(), + }; + } + + /** + * Map the current Redux transform stack for `threadsKey` to FilterEntry[], + * grouping consecutive transforms that came from the same `filter push` + * (each push is recorded as one size in _pushGroupSizes). Transforms not + * accounted for by any group — e.g. URL-loaded ones encountered before the + * group map was seeded — each become their own size-1 entry. + */ + private _collectFilterEntries(threadsKey: ThreadsKey): FilterEntry[] { + const stack = getTransformStack(this._store.getState(), threadsKey); + const groups = this._syncPushGroups(threadsKey); + const entries: FilterEntry[] = []; + let offset = 0; + for (const size of groups) { + const transforms = stack.slice(offset, offset + size); + if (transforms.length === 0) { + break; + } + entries.push({ + index: entries.length + 1, + transforms, + description: describeTransformGroup(transforms), + }); + offset += size; + } + return entries; + } + + /** + * Push the Redux transforms for a filter spec as one filter entry. `--merge + * f-1,f-2` dispatches two Redux transforms but shows up — and pops — as a + * single entry. + */ + 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 groups = this._syncPushGroups(threadsKey); + const countBefore = getTransformStack( + this._store.getState(), + threadsKey + ).length; + pushSpecTransforms(this._store, threadsKey, spec); + const countAfter = getTransformStack( + this._store.getState(), + threadsKey + ).length; + groups.push(countAfter - countBefore); + const filters = this._collectFilterEntries(threadsKey); + const pushed = filters[filters.length - 1]; + + return { + type: 'filter-stack', + threadHandle: actualHandle, + filters, + action: 'push', + message: `Pushed filter ${pushed.index}: ${pushed.description}`, + }; + } + + /** + * Pop the last `count` filter entries (default 1). Each entry is one + * previous `filter push` — multi-transform pushes (e.g. `--merge f-1,f-2`) + * undo as a single entry because that's how they were shown. + */ + 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 groups = this._syncPushGroups(threadsKey); + const before = this._collectFilterEntries(threadsKey); + const toPop = Math.max(0, Math.min(count, groups.length)); + let transformsToPop = 0; + for (let i = 0; i < toPop; i++) { + transformsToPop += groups.pop()!; + } + const beforeStackLength = getTransformStack( + this._store.getState(), + threadsKey + ).length; + if (transformsToPop > 0) { + this._store.dispatch( + popTransformsFromStackForThreads( + threadsKey, + beforeStackLength - transformsToPop + ) + ); + } + const filters = this._collectFilterEntries(threadsKey); + const removed = before.slice(before.length - toPop).reverse(); + + let message: string; + if (toPop === 0) { + message = 'No filters to pop'; + } else if (removed.length === 1) { + message = `Popped filter: ${removed[0].description}`; + } else { + message = `Popped ${toPop} filters: ${removed.map((f) => f.description).join('; ')}`; + } + + return { + type: 'filter-stack', + threadHandle: actualHandle, + filters, + action: 'pop', + message, + }; + } + + /** + * Clear all transforms from the thread's transform 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 entryCount = this._syncPushGroups(threadsKey).length; + this._pushGroupSizes.set(threadsKey, []); + if (entryCount > 0) { + this._store.dispatch(popTransformsFromStackForThreads(threadsKey, 0)); + } + + return { + type: 'filter-stack', + threadHandle: actualHandle, + filters: [], + action: 'clear', + message: + entryCount === 0 + ? 'No filters to clear' + : `Cleared ${entryCount} filter(s)`, + }; + } + + /** + * List the thread's full Redux transform stack as filter entries. + */ + 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); + + return { + type: 'filter-stack', + threadHandle: actualHandle, + filters: this._collectFilterEntries(threadsKey), + }; + } + + /** + * Resolve thread indexes, apply idle/search/ephemeral-filter wrappers, collect, + * and attach common metadata. Shared by threadSamples, threadSamplesTopDown, + * and threadSamplesBottomUp. + */ + private _runWithSampleFilters( + threadHandle: string | undefined, + includeIdle: boolean, + search: string | undefined, + sampleFilters: SampleFilterSpec[] | undefined, + collect: () => T + ): WithContext< + T & { + activeOnly: boolean; + search?: string; + activeFilters?: FilterEntry[]; + ephemeralFilters?: SampleFilterSpec[]; + } + > { + const activeOnly = !includeIdle; + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const withIdle = includeIdle + ? () => this._withIncludedIdle(collect) + : collect; + const withSearch = search + ? () => this._withCallTreeSearch(search, withIdle) + : withIdle; + const result = + sampleFilters && sampleFilters.length > 0 + ? this._withEphemeralFilters(threadIndexes, sampleFilters, withSearch) + : withSearch(); + const activeFilters = this._collectFilterEntries( + getThreadsKey(threadIndexes) + ); + return { + ...result, + activeOnly, + search: search || undefined, + activeFilters: activeFilters.length > 0 ? activeFilters : undefined, + ephemeralFilters: + sampleFilters && sampleFilters.length > 0 ? sampleFilters : undefined, + context: this._getContext(), + }; + } + + /** + * 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; + + try { + for (const spec of filters) { + pushSpecTransforms(this._store, threadsKey, spec); + } + return fn(); + } finally { + this._store.dispatch( + popTransformsFromStackForThreads(threadsKey, stackLengthBefore) + ); + } + } + + /** + * Turn on the "include idle samples" toggle around a computation, then + * restore the previous value. Used for the --include-idle slow path; the + * default CLI state already excludes idle, so no-wrap is the fast path. + */ + private _withIncludedIdle(fn: () => T): T { + const previous = getIncludeIdleSamples(this._store.getState()); + if (previous) { + return fn(); + } + this._store.dispatch(changeIncludeIdleSamples(true)); + try { + return fn(); + } finally { + this._store.dispatch(changeIncludeIdleSamples(previous)); + } + } + + /** + * 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)); + } + } + + private _buildBaseStatus(state: ReturnType) { + const profile = getProfile(state); + const rootRange = getProfileRootRange(state); + const committedRanges = getAllCommittedRanges(state); + const selectedThreadIndexes = getSelectedThreadIndexes(state); + + const selectedThreadHandle = + selectedThreadIndexes.size > 0 + ? this._threadMap.handleForThreadIndexes(selectedThreadIndexes) + : null; + + const selectedThreads = Array.from(selectedThreadIndexes).map( + (threadIndex) => ({ + threadIndex, + name: profile.threads[threadIndex].name, + }) + ); + + const zeroAt = rootRange.start; + const viewRanges = committedRanges.map((range) => { + const absoluteStart = range.start + zeroAt; + const absoluteEnd = range.end + zeroAt; + return { + start: absoluteStart, + startName: this._timestampManager.nameForTimestamp(absoluteStart), + end: absoluteEnd, + endName: this._timestampManager.nameForTimestamp(absoluteEnd), + }; + }); + + return { + selectedThreadHandle, + selectedThreads, + viewRanges, + rootRange: { start: rootRange.start, end: rootRange.end }, + }; + } + + /** + * 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 { selectedThreadHandle, selectedThreads, viewRanges, rootRange } = + this._buildBaseStatus(state); + const currentViewRange = + viewRanges.length > 0 ? viewRanges[viewRanges.length - 1] : null; + return { + selectedThreadHandle, + selectedThreads, + currentViewRange, + rootRange, + }; + } + + /** + * Get current session status including selected threads and view ranges. + */ + async getStatus(): Promise { + const state = this._store.getState(); + const { selectedThreadHandle, selectedThreads, viewRanges, rootRange } = + this._buildBaseStatus(state); + + // Collect active filter stacks: every thread with a non-empty Redux + // transform stack, whether pushed via the CLI or loaded from the URL. + const transformsPerThread = getProfileSpecificState(state).transforms; + const filterStacks = Object.entries(transformsPerThread) + .filter(([, stack]) => stack.length > 0) + .map(([rawKey]) => { + // ThreadsKey is number | string; JSON-ish keys come back as strings. + const threadsKey: ThreadsKey = /^-?\d+$/.test(rawKey) + ? Number(rawKey) + : rawKey; + return { + threadsKey, + threadHandle: this._threadMap.handleForKey(threadsKey), + filters: this._collectFilterEntries(threadsKey), + }; + }); + + return { + type: 'status', + selectedThreadHandle, + selectedThreads, + viewRanges, + rootRange, + filterStacks, + }; + } + + /** + * Expand a function handle to show the full untruncated name. + */ + async functionExpand( + functionHandle: string + ): Promise> { + const state = this._store.getState(); + const profile = getProfile(state); + const { funcTable, resourceTable, stringArray } = profile.shared; + + // Look up the function + const funcIndex = parseFunctionHandle(functionHandle, funcTable.length); + const funcName = stringArray[funcTable.name[funcIndex]]; + const library = getLibForFunc( + funcIndex, + funcTable, + resourceTable, + profile.libs + )?.name; + const fullName = library ? `${library}!${funcName}` : funcName; + + return { + type: 'function-expand', + functionHandle, + funcIndex, + 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); + const { funcTable, resourceTable, stringArray } = profile.shared; + + // Look up the function + 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']; + + if (resourceIndex !== -1) { + resource = { + name: stringArray[resourceTable.name[resourceIndex]], + index: resourceIndex, + }; + } + + const lib = getLibForFunc( + funcIndex, + funcTable, + resourceTable, + profile.libs + ); + if (lib) { + library = { + name: lib.name, + path: lib.path, + debugName: lib.debugName, + debugPath: lib.debugPath, + breakpadId: lib.breakpadId, + }; + } + + const fullName = lib ? `${lib.name}!${funcName}` : funcName; + + return { + type: 'function-info', + functionHandle, + funcIndex, + 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 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() }; + } + + /** + * 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. + */ + 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. + */ + async threadFunctions( + threadHandle?: string, + filterOptions?: FunctionFilterOptions, + includeIdle: boolean = false, + sampleFilters?: SampleFilterSpec[] + ): Promise> { + const activeOnly = !includeIdle; + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + const collect = () => + collectThreadFunctions( + this._store, + this._threadMap, + threadHandle, + filterOptions + ); + const withIdle = includeIdle + ? () => this._withIncludedIdle(collect) + : collect; + const result = + sampleFilters && sampleFilters.length > 0 + ? this._withEphemeralFilters(threadIndexes, sampleFilters, withIdle) + : withIdle(); + const activeFilters = this._collectFilterEntries( + getThreadsKey(threadIndexes) + ); + return { + ...result, + activeOnly, + activeFilters: activeFilters.length > 0 ? activeFilters : undefined, + ephemeralFilters: + sampleFilters && sampleFilters.length > 0 ? sampleFilters : undefined, + 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() }; + } + + /** + * 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 result = await computeFunctionAnnotate( + this._store, + this._threadMap, + this._archiveCache, + functionHandle, + mode, + symbolServerUrl, + contextOption + ); + 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..e1f4228273 --- /dev/null +++ b/src/profile-query/loader.ts @@ -0,0 +1,292 @@ +/* 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 { + doSymbolicateProfile, + finalizeProfileView, + loadProfile, + triggerLoadingFromUrl, + waitingForProfileFromFile, +} from '../actions/receive-profile'; +import { changeIncludeIdleSamples } from '../actions/profile-view'; +import { updateUrlState } from '../actions/app'; +import { stateFromLocation } from '../app-logic/url-handling'; +import { getProfileRootRange } from 'firefox-profiler/selectors/profile'; +import { + getUrlState, + getSymbolServerUrl, +} from 'firefox-profiler/selectors/url-state'; +import { + extractProfileUrlFromProfilerUrl, + fetchProfile, +} from '../utils/profile-fetch'; +import type { TemporaryError } from '../utils/errors'; +import type { Store } from '../types/store'; +import type { StartEndRange, Profile, UrlState } from 'firefox-profiler/types'; +import type { ProfileUpgradeInfo } from 'firefox-profiler/profile-logic/processed-profile-versioning'; +import { SymbolStore } from '../profile-logic/symbol-store'; +import * as MozillaSymbolicationAPI from '../profile-logic/mozilla-symbolication-api'; + +/** + * Load phases, reported via the onPhaseChange callback. Mirrors the visible + * states the web UI goes through (`urlSetupPhase` + `symbolicationStatus`): + * fetching -> downloading/reading the profile + * processing -> parsing + PROFILE_LOADED + VIEW_FULL_PROFILE + * symbolicating -> doSymbolicateProfile in progress + * ready -> everything done + */ +export type LoadPhase = 'fetching' | 'processing' | 'symbolicating' | 'ready'; + +export interface LoadOptions { + /** Overrides both ?symbolServer= and the default server. */ + explicitSymbolServerUrl?: string; + /** Skip symbolication entirely (intended for CLI tests). */ + skipSymbolication?: boolean; + /** Reports loading phase transitions. */ + onPhaseChange?: (phase: LoadPhase) => void; +} + +export interface LoadResult { + store: Store; + rootRange: StartEndRange; +} + +function isUrl(input: string): boolean { + return input.startsWith('http://') || input.startsWith('https://'); +} + +function isProfilerFrontendUrl(url: string): boolean { + return ( + url.includes('profiler.firefox.com') || url.includes('share.firefox.dev') + ); +} + +async function followRedirects(url: string): Promise { + const response = await fetch(url, { method: 'HEAD', redirect: 'follow' }); + return response.url; +} + +/** + * Build a Node-compatible SymbolStore that fetches from a remote symbol server. + * Unlike the browser implementation, this does not cache in IndexedDB and has + * no browser fallback path. + */ +function createNodeSymbolStore(symbolServerUrl: string): SymbolStore { + return new SymbolStore({ + requestSymbolsFromServer: async (requests) => + 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('Symbol-table-from-browser is not supported in the CLI'); + }, + }); +} + +/** + * Parsed form of the user-provided input. Exactly one of `filePath` or + * `fetchUrl` is populated. When `fetchUrl` is populated, `location` is set + * iff the input was a profiler.firefox.com URL (so we have view settings to + * parse via `stateFromLocation`). + */ +type ParsedInput = + | { kind: 'file'; filePath: string } + | { kind: 'url'; fetchUrl: string; location: Location | null }; + +async function parseInput(input: string): Promise { + if (!isUrl(input)) { + return { kind: 'file', filePath: input }; + } + + // Short-URL redirects (share.firefox.dev -> profiler.firefox.com) must be + // followed before we can extract view settings from the URL. + let resolvedUrl = input; + if (input.includes('share.firefox.dev')) { + console.log('Following redirect from short URL...'); + resolvedUrl = await followRedirects(input); + console.log(`Redirected to: ${resolvedUrl}`); + } + + if (isProfilerFrontendUrl(resolvedUrl)) { + const fetchUrl = extractProfileUrlFromProfilerUrl(resolvedUrl); + if (fetchUrl === null) { + throw new Error( + `Unable to extract profile URL from profiler URL: ${resolvedUrl}` + ); + } + const parsed = new URL(resolvedUrl); + const location = { + pathname: parsed.pathname, + search: parsed.search, + hash: parsed.hash, + } as Location; + return { kind: 'url', fetchUrl, location }; + } + + // Direct URL pointing at a profile file. No view settings to parse. + return { kind: 'url', fetchUrl: input, location: null }; +} + +async function fetchAndParseProfile( + fetchUrl: string +): Promise<{ profile: Profile; upgradeInfo: ProfileUpgradeInfo }> { + console.log(`Fetching profile from ${fetchUrl}`); + const response = await fetchProfile({ + url: fetchUrl, + onTemporaryError: (e: TemporaryError) => { + if (e.attempt) { + console.log(`Retry ${e.attempt.count}/${e.attempt.total}...`); + } + }, + }); + + if (response.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' + ); + } + + const upgradeInfo: ProfileUpgradeInfo = {}; + const profile = await unserializeProfileOfArbitraryFormat( + response.profile, + fetchUrl, + upgradeInfo + ); + if (profile === undefined) { + throw new Error('Unable to parse the profile.'); + } + return { profile, upgradeInfo }; +} + +async function readAndParseFile( + filePath: string +): Promise<{ profile: Profile; upgradeInfo: ProfileUpgradeInfo }> { + const bytes = fs.readFileSync(filePath, null); + const upgradeInfo: ProfileUpgradeInfo = {}; + const profile = await unserializeProfileOfArbitraryFormat( + bytes, + filePath, + upgradeInfo + ); + if (profile === undefined) { + throw new Error('Unable to parse the profile.'); + } + return { profile, upgradeInfo }; +} + +/** + * Override the symbolServerUrl field of the current URL state. `symbolServerUrl` + * has no dedicated action (the reducer is a read-only pass-through), so the + * only way to change it is to replace the whole UrlState via UPDATE_URL_STATE. + */ +function overrideSymbolServerUrl(store: Store, symbolServerUrl: string): void { + const current = getUrlState(store.getState()); + const next: UrlState = { ...current, symbolServerUrl }; + store.dispatch(updateUrlState(next)); +} + +/** + * Load a profile from a file path or URL. + * + * Mirrors the web app's profile loading pipeline: + * 1. Dispatch source-specific waiting action (sets dataSource in URL state). + * 2. Fetch/read + parse the profile, collecting upgradeInfo as an outparam. + * 3. Dispatch loadProfile(profile, {}, initialLoad=true) to fire PROFILE_LOADED. + * 4. For profiler.firefox.com URLs, build URL state via stateFromLocation + * (which runs the URL upgraders using upgradeInfo) and dispatch + * updateUrlState. + * 5. If --symbol-server was provided, overwrite urlState.symbolServerUrl. + * 6. Dispatch finalizeProfileView(null) to fire VIEW_FULL_PROFILE. In Node + * getSymbolStore() returns null, so symbolication is not kicked off here. + * 7. Symbolicate through Redux via doSymbolicateProfile, reading the symbol + * server URL from getSymbolServerUrl (--symbol-server > ?symbolServer= > + * default Mozilla server). + */ +export async function loadProfileFromFileOrUrl( + input: string, + options: LoadOptions = {} +): Promise { + const { explicitSymbolServerUrl, skipSymbolication, onPhaseChange } = options; + const store = createStore(); + console.log(`Loading profile from ${input}`); + + onPhaseChange?.('fetching'); + const parsed = await parseInput(input); + + let profile: Profile; + let upgradeInfo: ProfileUpgradeInfo; + if (parsed.kind === 'file') { + store.dispatch(waitingForProfileFromFile()); + ({ profile, upgradeInfo } = await readAndParseFile(parsed.filePath)); + } else { + store.dispatch(triggerLoadingFromUrl(parsed.fetchUrl)); + ({ profile, upgradeInfo } = await fetchAndParseProfile(parsed.fetchUrl)); + } + + onPhaseChange?.('processing'); + + // PROFILE_LOADED. initialLoad=true suppresses auto-finalize so we can + // apply URL state (and any --symbol-server override) before finalize runs. + await store.dispatch(loadProfile(profile, {}, /* initialLoad */ true)); + + // For profiler.firefox.com URLs, parse view settings (selected threads, + // transforms, committed ranges, symbolServer, etc.) into a fresh UrlState. + // For direct URLs and files, waitingForProfileFromFile / triggerLoadingFromUrl + // already set dataSource; all other URL state fields stay at reducer defaults, + // matching what the web app does for these inputs. + if (parsed.kind === 'url' && parsed.location !== null) { + const urlState = stateFromLocation(parsed.location, { + profile, + upgradeInfo, + }); + store.dispatch(updateUrlState(urlState)); + } + + if (explicitSymbolServerUrl) { + overrideSymbolServerUrl(store, explicitSymbolServerUrl); + } + + // VIEW_FULL_PROFILE. finalizeProfileView reads URL state, so this must come + // after updateUrlState. In Node, its internal symbolication attempt is a + // no-op because getSymbolStore returns null without window.indexedDB. + await store.dispatch(finalizeProfileView(null)); + + if (!skipSymbolication && profile.meta.symbolicated === false) { + onPhaseChange?.('symbolicating'); + const symbolServerUrl = getSymbolServerUrl(store.getState()); + console.log(`Symbolicating profile using ${symbolServerUrl}...`); + const symbolStore = createNodeSymbolStore(symbolServerUrl); + try { + await doSymbolicateProfile(store.dispatch, profile, symbolStore); + console.log('Symbolication complete'); + } catch (e) { + console.warn( + `Symbolication failed: ${e}. Loading profile without symbols.` + ); + } + } + + // The web defaults to "include idle samples"; the CLI defaults to "exclude". + store.dispatch(changeIncludeIdleSamples(false)); + + onPhaseChange?.('ready'); + + 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..7b8ccefc64 --- /dev/null +++ b/src/profile-query/marker-map.ts @@ -0,0 +1,70 @@ +/* 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(); + _markerToHandle: 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 { + const threadsKey = getThreadsKey(threadIndexes); + const reverseKey = `${threadsKey}:${markerIndex}`; + const existing = this._markerToHandle.get(reverseKey); + if (existing !== undefined) { + return existing; + } + + // Create a new handle + const handle = 'm-' + this._nextHandleId++; + this._handleToMarker.set(handle, { + threadIndexes, + threadsKey, + markerIndex, + }); + this._markerToHandle.set(reverseKey, handle); + 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..0c9ae36b9c --- /dev/null +++ b/src/profile-query/process-thread-list.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/. */ + +export type ThreadInfo = { + threadIndex: number; + name: string; + tid: number | string; + cpuMs: number; + pid: string; +}; + +export type ProcessInfo = { + pid: string; + processIndex: number; + name: string; + cpuMs: number; + threads: Array<{ + threadIndex: number; + name: string; + tid: number | string; + cpuMs: number; + }>; +}; + +export type ProcessListItem = { + processIndex: number; + pid: string; + name: string; + etld1?: string; + cpuMs: number; + threads: Array<{ + threadIndex: number; + name: string; + tid: number | 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, + showAll: boolean = false +): ProcessThreadListResult { + // Aggregate threads by process + const processCPUMap = new Map(); + + threads.forEach((thread) => { + const { pid, threadIndex, name, tid, cpuMs } = thread; + const existing = processCPUMap.get(pid); + + if (existing) { + existing.cpuMs += cpuMs; + existing.threads.push({ threadIndex, name, tid, 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, tid, 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); + + 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)); + + // 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) + ); + const shownProcessPids = new Set(processesToShow.map((p) => 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.filter( + (processInfo) => !shownProcessPids.has(processInfo.pid) + ); + 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..d9e0798ab8 --- /dev/null +++ b/src/profile-query/thread-map.ts @@ -0,0 +1,64 @@ +/* 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, ThreadsKey } 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(','); + } + + /** + * 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/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..48eb230c73 --- /dev/null +++ b/src/profile-query/timestamps.ts @@ -0,0 +1,311 @@ +/* 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(); + _timestampToName: 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 { + const cached = this._timestampToName.get(ts); + if (cached !== undefined) { + return cached; + } + + 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-'); + } + + this._nameToTimestamp.set(name, ts); + this._timestampToName.set(ts, name); + 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..8a43ee7ed8 --- /dev/null +++ b/src/profile-query/types.ts @@ -0,0 +1,686 @@ +/* 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 profiler-cli. + */ + +import type { Transform } from 'firefox-profiler/types'; + +// ===== 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 + 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 = { + searchString?: string; // Substring search in function names + minSelf?: number; // Minimum self time percentage (0-100) + limit?: number; // Limit the number of functions in output +}; + +// ===== Sample Filter Stack ===== + +/** + * 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 + | { 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 }; + +/** + * One entry in the filter stack shown/managed by the CLI. + * + * One entry corresponds to one `filter push`. A push may dispatch multiple + * Redux transforms (e.g. `--merge f-1,f-2` dispatches two merge-function + * transforms); they are grouped into a single entry here so the CLI view + * matches what the user typed. Transforms already present when the session + * started (e.g. loaded from a profiler.firefox.com URL) each form their own + * single-transform entry so they remain individually poppable. + */ +export type FilterEntry = { + /** 1-based position in the filter list. */ + index: number; + /** The raw Redux transforms backing this entry (1 or more). */ + transforms: Transform[]; + /** Human-readable description. */ + description: string; +}; + +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 + +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; + }; + /** Filter stacks for all threads that have active filters */ + filterStacks: Array<{ + threadsKey: string | number; + threadHandle: string; + filters: FilterEntry[]; + }>; +}; + +// ===== Function Commands ===== + +export type FunctionExpandResult = { + type: 'function-expand'; + functionHandle: string; + funcIndex: number; + name: string; + fullName: string; + library?: string; +}; + +export type FunctionInfoResult = { + type: 'function-info'; + functionHandle: string; + funcIndex: number; + name: string; + fullName: string; + isJS: boolean; + relevantForJS: boolean; + resource?: { + name: string; + index: number; + }; + library?: { + name: string; + path: string; + debugName?: string; + debugPath?: string; + breakpadId?: string; + }; +}; + +// ===== 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 = { + 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; + }; + warning?: string; // Present if the range extends outside the profile duration +}; + +// ===== Thread Commands ===== + +export type ThreadSelectResult = { + type: 'thread-select'; + threadHandle: string; + threadNames: string[]; +}; + +export type ThreadInfoResult = { + type: 'thread-info'; + threadHandle: string; + name: string; + friendlyName: string; + tid: number | 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; + activeOnly?: boolean; + search?: string; + activeFilters?: FilterEntry[]; + ephemeralFilters?: SampleFilterSpec[]; + 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; + activeOnly?: boolean; + search?: string; + activeFilters?: FilterEntry[]; + ephemeralFilters?: SampleFilterSpec[]; + regularCallTree: CallTreeNode; +}; + +export type ThreadSamplesBottomUpResult = { + type: 'thread-samples-bottom-up'; + threadHandle: string; + friendlyThreadName: string; + activeOnly?: boolean; + search?: string; + activeFilters?: FilterEntry[]; + ephemeralFilters?: SampleFilterSpec[]; + 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 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; + 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[]; + flatMarkers?: FlatMarkerItem[]; +}; + +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 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; + friendlyThreadName: string; + activeOnly?: boolean; + activeFilters?: FilterEntry[]; + ephemeralFilters?: SampleFilterSpec[]; + 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; +}; + +// ===== 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 = { + type: 'profile-info'; + name: string; + platform: string; + threadCount: number; + processCount: number; + showAll?: boolean; + searchQuery?: string; + processes: Array<{ + processIndex: number; + pid: string; + name: string; + etld1?: string; + cpuMs: number; + startTime?: number; + startTimeName?: string; + endTime?: number | null; + endTimeName?: string | null; + threads: Array<{ + threadIndex: number; + threadHandle: string; + name: string; + tid: number | 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 29aa887f94..59db24d074 100644 --- a/src/selectors/per-thread/thread.tsx +++ b/src/selectors/per-thread/thread.tsx @@ -205,6 +205,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 @@ -417,6 +436,7 @@ export function getBasicThreadSelectorsPerThread( getSamplesTable, getTracedValuesBuffer, getActivitySlices, + getRangeFilteredActivitySlices, getSamplesWeightType, getNativeAllocations, getJsAllocations, diff --git a/src/selectors/profile.ts b/src/selectors/profile.ts index 2807de8e06..236ad21ce4 100644 --- a/src/selectors/profile.ts +++ b/src/selectors/profile.ts @@ -809,6 +809,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, @@ -827,6 +843,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 || 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/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; +} diff --git a/tsconfig.json b/tsconfig.json index 28b14b261a..f6c0bf5a04 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", + "profiler-cli/**/*.ts", + "__mocks__/**/*.ts" + ], "exclude": ["node_modules", "dist"] } From 1f391c70fe95f07da959c4000af2f3d082c255e9 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 25 Jul 2025 21:14:45 -0400 Subject: [PATCH 7/8] Add tests for profile-query library and profiler-cli --- .github/workflows/ci.yml | 15 + bin/output-fixing-commands.js | 1 + eslint.config.mjs | 2 +- jest.config.js | 77 +- package.json | 5 +- .../src/test/integration/basic.test.ts | 362 ++++++ .../test/integration/daemon-startup.test.ts | 124 ++ .../src/test/integration/sessions.test.ts | 237 ++++ profiler-cli/src/test/integration/setup.ts | 11 + profiler-cli/src/test/integration/utils.ts | 188 +++ .../call-tree-formatting.test.ts.snap | 318 +++++ .../test/unit/call-tree-formatting.test.ts | 600 +++++++++ .../src/test/unit/marker-formatting.test.ts | 150 +++ .../src/test/unit/network-formatting.test.ts | 257 ++++ profiler-cli/src/test/unit/session.test.ts | 445 +++++++ src/test/unit/profile-query/call-tree.test.ts | 468 +++++++ .../unit/profile-query/cpu-activity.test.ts | 48 + .../unit/profile-query/function-list.test.ts | 591 +++++++++ .../unit/profile-query/marker-utils.test.ts | 1082 +++++++++++++++++ .../profile-query/process-thread-list.test.ts | 436 +++++++ .../profile-querier-annotate.test.ts | 293 +++++ .../profile-query/profile-querier.test.ts | 369 ++++++ .../profile-query/time-range-parser.test.ts | 145 +++ .../unit/profile-query/timestamps.test.ts | 133 ++ 24 files changed, 6340 insertions(+), 17 deletions(-) create mode 100644 profiler-cli/src/test/integration/basic.test.ts create mode 100644 profiler-cli/src/test/integration/daemon-startup.test.ts create mode 100644 profiler-cli/src/test/integration/sessions.test.ts create mode 100644 profiler-cli/src/test/integration/setup.ts create mode 100644 profiler-cli/src/test/integration/utils.ts create mode 100644 profiler-cli/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap create mode 100644 profiler-cli/src/test/unit/call-tree-formatting.test.ts create mode 100644 profiler-cli/src/test/unit/marker-formatting.test.ts create mode 100644 profiler-cli/src/test/unit/network-formatting.test.ts create mode 100644 profiler-cli/src/test/unit/session.test.ts create mode 100644 src/test/unit/profile-query/call-tree.test.ts create mode 100644 src/test/unit/profile-query/cpu-activity.test.ts create mode 100644 src/test/unit/profile-query/function-list.test.ts create mode 100644 src/test/unit/profile-query/marker-utils.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-annotate.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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 682b6c8d4d..1a674f4a01 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: 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); diff --git a/eslint.config.mjs b/eslint.config.mjs index add4776217..2c0d9543d0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -254,7 +254,7 @@ export default defineConfig( // Test files override { - files: ['src/test/**/*'], + files: ['src/test/**/*', 'profiler-cli/src/test/**/*'], languageOptions: { globals: { ...globals.jest, diff --git a/jest.config.js b/jest.config.js index 67cbc9e4ca..3b89ea2c4e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,21 +2,14 @@ * 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/. */ -module.exports = { - 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', - +// 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'], - - collectCoverageFrom: [ - 'src/**/*.{js,jsx,ts,tsx}', - '!**/node_modules/**', - '!src/types/libdef/**', - ], + moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], + resolver: './jest-resolver.js', transform: { '\\.([jt]sx?|mjs)$': 'babel-jest', @@ -43,5 +36,61 @@ module.exports = { escapeString: true, printBasicPrototype: true, }, - verbose: false, +}; + +const allProjects = [ + // ======================================================================== + // Browser Tests (React/browser environment) + // ======================================================================== + { + ...browserEnvConfig, + displayName: 'browser', + testMatch: ['/src/**/*.test.{js,jsx,ts,tsx}'], + + collectCoverageFrom: [ + 'src/**/*.{js,jsx,ts,tsx}', + '!**/node_modules/**', + '!src/types/libdef/**', + ], + }, + + // ======================================================================== + // CLI Unit Tests (browser/jsdom environment - imports browser-side fixtures) + // ======================================================================== + { + ...browserEnvConfig, + displayName: 'cli', + testMatch: ['/profiler-cli/src/test/unit/**/*.test.ts'], + }, + + // ======================================================================== + // CLI Integration Tests (Node.js environment - spawns real processes) + // ======================================================================== + { + displayName: 'cli-integration', + testMatch: ['/profiler-cli/src/test/integration/**/*.test.ts'], + + testEnvironment: 'node', + + setupFilesAfterEnv: ['./profiler-cli/src/test/integration/setup.ts'], + + // Integration tests can be slow (loading profiles, spawning processes) + testTimeout: 30000, + + moduleFileExtensions: ['ts', 'js'], + + transform: { + '\\.([jt]sx?|mjs)$': 'babel-jest', + }, + }, +]; + +// Filter projects by JEST_PROJECTS env var (comma-separated displayNames). +// Preferred over --selectProjects because that CLI flag is variadic and +// swallows positional args like `yarn test process-profile.ts`. +const filter = process.env.JEST_PROJECTS; +module.exports = { + projects: filter + ? allProjects.filter((p) => filter.split(',').includes(p.displayName)) + : allProjects, }; diff --git a/package.json b/package.json index 6c2dd2ad53..18bade9fb1 100644 --- a/package.json +++ b/package.json @@ -47,15 +47,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_PROJECTS=browser jest", "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-profiler-cli && node bin/output-fixing-commands.js cross-env LC_ALL=C TZ=UTC NODE_ENV=test JEST_PROJECTS=cli,cli-integration jest", "postinstall": "patch-package" }, "license": "MPL-2.0", diff --git a/profiler-cli/src/test/integration/basic.test.ts b/profiler-cli/src/test/integration/basic.test.ts new file mode 100644 index 0000000000..8427403d7c --- /dev/null +++ b/profiler-cli/src/test/integration/basic.test.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/. */ + +/** + * Basic CLI functionality tests. + */ + +import { readdir, readFile, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { + createTestContext, + cleanupTestContext, + cli, + cliFail, + type CliTestContext, +} from './utils'; + +describe('profiler-cli basic functionality', () => { + let ctx: CliTestContext; + + beforeEach(async () => { + ctx = await createTestContext(); + }); + + afterEach(async () => { + await cleanupTestContext(ctx); + }); + + it('load creates a session', async () => { + const result = await cli(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); + // 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'); + }); + + it('profile info works after load', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + 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 cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const result = await cli(ctx, ['thread', 'select', 't-0']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Selected thread'); + expect(result.stdout).toContain('t-0'); + }); + + it('stop cleans up session', async () => { + 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); + expect(files.filter((f) => f.endsWith('.sock'))).toHaveLength(0); + }); + + it('load fails for missing file', async () => { + const result = await cliFail(ctx, ['load', '/nonexistent/file.json']); + + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('not found'); + }); + + it('profile info fails without active session', async () => { + const result = await cliFail(ctx, ['profile', 'info']); + + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('No active session'); + }); + + it('multiple profile info calls work (daemon stays running)', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + // First call + const result1 = await cli(ctx, ['profile', 'info']); + expect(result1.exitCode).toBe(0); + + // Second call - should still work (daemon running) + 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 cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const minDurationResult = await cli(ctx, [ + 'thread', + 'markers', + '--json', + '--min-duration', + '0', + ]); + expect(minDurationResult.stdout).toContain('"minDuration": 0'); + + const maxDurationResult = await cli(ctx, [ + 'thread', + 'markers', + '--json', + '--max-duration', + '0', + ]); + expect(maxDurationResult.stdout).toContain('"maxDuration": 0'); + }); + + it('numeric zero function filters are preserved instead of being ignored', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const result = await cli(ctx, [ + 'thread', + 'functions', + '--json', + '--min-self', + '0', + ]); + + expect(result.stdout).toContain('"minSelf": 0'); + }); + + it('sticky filters are isolated per thread and reported in status', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + await cli(ctx, ['thread', 'select', 't-0']); + + await cli(ctx, ['filter', 'push', '--merge', 'f-1,f-2']); + + const filterListResult = await cli(ctx, ['filter', 'list', '--json']); + const filterList = JSON.parse(filterListResult.stdout) as { + type: string; + threadHandle: string; + filters: Array<{ + index: number; + transforms: Array<{ type: string; funcIndex?: number }>; + description: string; + }>; + }; + + expect(filterList.type).toBe('filter-stack'); + expect(filterList.threadHandle).toBe('t-0'); + // Multi-func push collapses into one entry backed by multiple transforms. + expect(filterList.filters).toHaveLength(1); + expect(filterList.filters[0].transforms).toEqual([ + { type: 'merge-function', funcIndex: 1 }, + { type: 'merge-function', funcIndex: 2 }, + ]); + expect(filterList.filters[0].description).toBe('merge: f-1, f-2'); + + const statusResult = await cli(ctx, ['status', '--json']); + const status = JSON.parse(statusResult.stdout) as { + type: string; + filterStacks: Array<{ + threadHandle: string; + filters: Array<{ + transforms: Array<{ type: string; funcIndex?: number }>; + }>; + }>; + }; + + expect(status.type).toBe('status'); + expect(status.filterStacks).toHaveLength(1); + expect(status.filterStacks[0]).toEqual( + expect.objectContaining({ + threadHandle: 't-0', + filters: [ + expect.objectContaining({ + transforms: [ + { type: 'merge-function', funcIndex: 1 }, + { type: 'merge-function', funcIndex: 2 }, + ], + }), + ], + }) + ); + + await cli(ctx, ['thread', 'select', 't-1']); + + const otherThreadFilterListResult = await cli(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 cli(ctx, [ + 'filter', + 'list', + '--thread', + 't-0', + '--json', + ]); + const explicitThreadFilterList = JSON.parse( + explicitThreadFilterListResult.stdout + ) as { + threadHandle: string; + filters: Array<{ + transforms: Array<{ type: string; funcIndex?: number }>; + }>; + }; + + expect(explicitThreadFilterList.threadHandle).toBe('t-0'); + expect(explicitThreadFilterList.filters).toHaveLength(1); + expect(explicitThreadFilterList.filters[0].transforms).toEqual([ + { type: 'merge-function', funcIndex: 1 }, + { type: 'merge-function', funcIndex: 2 }, + ]); + + // One pop removes the whole entry (both underlying transforms). + await cli(ctx, ['filter', 'pop', '--thread', 't-0']); + const afterPopResult = await cli(ctx, [ + 'filter', + 'list', + '--thread', + 't-0', + '--json', + ]); + const afterPop = JSON.parse(afterPopResult.stdout) as { + filters: unknown[]; + }; + expect(afterPop.filters).toHaveLength(0); + }); + + it('ephemeral sample filters do not persist into session state', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const samplesResult = await cli(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 cli(ctx, ['filter', 'list', '--json']); + const filterList = JSON.parse(filterListResult.stdout) as { + filters: unknown[]; + }; + expect(filterList.filters).toHaveLength(0); + + const statusResult = await cli(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 cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const result = await cliFail(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'); + }); + + it('build hash mismatch stops the daemon before cleaning up the session', async () => { + const loadResult = await cli(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 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'); + + 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/profiler-cli/src/test/integration/daemon-startup.test.ts b/profiler-cli/src/test/integration/daemon-startup.test.ts new file mode 100644 index 0000000000..0afae28101 --- /dev/null +++ b/profiler-cli/src/test/integration/daemon-startup.test.ts @@ -0,0 +1,124 @@ +/* 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. + */ + +import { readFile, access } from 'fs/promises'; +import { join } from 'path'; +import { + createTestContext, + cleanupTestContext, + cli, + cliFail, + type CliTestContext, +} from './utils'; +import { getSocketPath } from '../../session'; + +describe('daemon startup (two-phase)', () => { + let ctx: CliTestContext; + + beforeEach(async () => { + ctx = await createTestContext(); + }); + + afterEach(async () => { + await cleanupTestContext(ctx); + }); + + it('daemon creates socket and metadata before loading profile', async () => { + const startTime = Date.now(); + + const result = await cli(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'); + }); + + 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'); + await writeFile(invalidProfile, '{ invalid json content', 'utf-8'); + + const result = await cliFail(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); + }); + + 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 + + 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); + }); + + it('load blocks until profile is fully loaded', async () => { + // Start loading + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + // If load returned, profile should be ready immediately + 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 cli(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 = getSocketPath(ctx.sessionDir, sessionId); + const metadataPath = join(ctx.sessionDir, `${sessionId}.json`); + + // 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) + const metadata = JSON.parse(await readFile(metadataPath, 'utf-8')); + expect(metadata.pid).toBeNumber(); + expect(metadata.pid).toBeGreaterThan(0); + }); +}); diff --git a/profiler-cli/src/test/integration/sessions.test.ts b/profiler-cli/src/test/integration/sessions.test.ts new file mode 100644 index 0000000000..258d8028ef --- /dev/null +++ b/profiler-cli/src/test/integration/sessions.test.ts @@ -0,0 +1,237 @@ +/* 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. + */ + +import { access, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { + createTestContext, + cleanupTestContext, + cli, + cliFail, + type CliTestContext, +} from './utils'; + +describe('profiler-cli multiple concurrent sessions', () => { + let ctx: CliTestContext; + + beforeEach(async () => { + ctx = await createTestContext(); + }); + + afterEach(async () => { + await cleanupTestContext(ctx); + }); + + it('can run multiple sessions with explicit IDs', async () => { + const session1 = 'test-session-1'; + const session2 = 'test-session-2'; + + // Start two sessions + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + session1, + ]); + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + session2, + ]); + + // Query session1 explicitly + const result1 = await cli(ctx, ['profile', 'info', '--session', session1]); + expect(result1.stdout).toContain('This profile contains'); + + // Query current session (should be session2, the last loaded) + const result2 = await cli(ctx, ['profile', 'info']); + expect(result2.stdout).toContain('This profile contains'); + + // Stop all sessions (mix of positional arg and --session flag) + await cli(ctx, ['stop', session1]); + await cli(ctx, ['stop', '--session', session2]); + }); + + it('session list shows running sessions and marks the current one', async () => { + // Start two sessions + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + 'session-a', + ]); + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + 'session-b', + ]); + + // List sessions — session-b was loaded last, so it should be current + const result = await cli(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 cli(ctx, ['stop', '--all']); + }); + + it('session use switches the current session', async () => { + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + 'session-a', + ]); + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + 'session-b', + ]); + + // session-b is current; switch to 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 cli(ctx, ['session', 'list']); + expect(listResult.stdout).toMatch(/\* session-a/); + + await cli(ctx, ['stop', '--all']); + }); + + it('stop --all stops all sessions', async () => { + // Start multiple sessions + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + 'session-1', + ]); + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + 'session-2', + ]); + + // Stop all + await cli(ctx, ['stop', '--all']); + + // Verify no sessions + 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 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 cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + 'session-a', + ]); + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-2.json', + '--session', + 'session-b', + ]); + + // Switch to session-a (session-b is current) + await cli(ctx, ['session', 'use', 'session-a']); + + // Unqualified stop should stop session-a + await cli(ctx, ['stop']); + + // session-a is gone; session-b is still running + await cliFail(ctx, ['profile', 'info', '--session', 'session-a']); + const result = await cli(ctx, [ + 'profile', + 'info', + '--session', + 'session-b', + ]); + expect(result.exitCode).toBe(0); + + await cli(ctx, ['stop', '--all']); + }); + + it('reusing a live explicit session id fails without replacing the daemon', async () => { + const sessionId = 'shared-session'; + + await cli(ctx, [ + 'load', + 'src/test/fixtures/upgrades/processed-1.json', + '--session', + sessionId, + ]); + + const secondLoad = await cliFail(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 cli(ctx, ['profile', 'info', '--session', sessionId]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('This profile contains'); + }); + + 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`); + const currentPath = join(ctx.sessionDir, 'current.txt'); + + 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, + 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 cli(ctx, ['session', 'list']); + + 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/profiler-cli/src/test/integration/setup.ts b/profiler-cli/src/test/integration/setup.ts new file mode 100644 index 0000000000..9ba519e3c8 --- /dev/null +++ b/profiler-cli/src/test/integration/setup.ts @@ -0,0 +1,11 @@ +/* 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. + */ + +// Importing this makes jest-extended matchers available everywhere +import 'jest-extended/all'; diff --git a/profiler-cli/src/test/integration/utils.ts b/profiler-cli/src/test/integration/utils.ts new file mode 100644 index 0000000000..971484b6eb --- /dev/null +++ b/profiler-cli/src/test/integration/utils.ts @@ -0,0 +1,188 @@ +/* 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. + */ + +import { spawn } from 'child_process'; +import { mkdtemp, readdir, readFile, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const CLI_BIN = './profiler-cli/dist/profiler-cli.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 profiler-cli test session. + */ +export interface CliTestContext { + 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(), 'profiler-cli-test-')); + return { + sessionDir, + env: { + PROFILER_CLI_SESSION_DIR: sessionDir, + PROFILER_CLI_NO_SYMBOLICATE: '1', + }, + }; +} + +/** + * 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: CliTestContext): Promise { + await killSessionDaemons(ctx.sessionDir); + await rm(ctx.sessionDir, { recursive: true, force: true }); +} + +/** + * Run a profiler-cli command and expect it to succeed. + */ +export async function cli( + ctx: CliTestContext, + args: string[], + options?: { + timeout?: number; + } +): Promise { + const result = await exec(process.execPath, [CLI_BIN, ...args], { + env: ctx.env, + timeout: options?.timeout ?? 30000, + }); + + if (result.exitCode !== 0) { + const error = new Error(`Command failed with exit code ${result.exitCode}`); + Object.assign(error, result); + throw error; + } + + return result; +} + +/** + * Run a profiler-cli command and expect it to fail. + */ +export async function cliFail( + ctx: CliTestContext, + args: string[] +): Promise { + try { + await cli(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/profiler-cli/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap b/profiler-cli/src/test/unit/__snapshots__/call-tree-formatting.test.ts.snap new file mode 100644 index 0000000000..99ad7965f3 --- /dev/null +++ b/profiler-cli/src/test/unit/__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: 1s] + +Thread: Test Thread + +Bottom-Up Call Tree: +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`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Bottom-Up Call Tree: +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`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Bottom-Up Call Tree: +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-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`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +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%)" +`; + +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: 1s] + +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-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`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +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-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%)" +`; + +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: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +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-7. 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: 1s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-1. D [total: 28.6%, self: 28.6%] +└─ f-0. A [total: 28.6%, self: 0.0%] +f-2. E [total: 28.6%, self: 28.6%] +└─ 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%]" +`; + +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: 1s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-3. D [total: 100.0%, self: 100.0%] +└─ f-2. C [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`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Bottom-Up Call Tree: +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`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Bottom-Up Call Tree: +f-1. B [total: 30.0%, self: 30.0%] +└─ f-0. A [total: 30.0%, self: 0.0%] +f-2. C [total: 20.0%, self: 20.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%)" +`; + +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: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +f-0. Main [total: 100.0%, self: 0.0%] +├─ f-2. Loop [total: 90.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-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`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +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-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`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +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`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +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`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +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`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +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`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +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`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +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`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +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-4. C [total: 25.0%, self: 0.0%] +│ └─ ... (2 more children: combined 25.0%, max 12.5%) +└─ 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`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +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%)" +`; + +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: 1s] + +Thread: Test Thread + +Top-Down Call Tree: +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/profiler-cli/src/test/unit/call-tree-formatting.test.ts b/profiler-cli/src/test/unit/call-tree-formatting.test.ts new file mode 100644 index 0000000000..23a5dce71d --- /dev/null +++ b/profiler-cli/src/test/unit/call-tree-formatting.test.ts @@ -0,0 +1,600 @@ +/* 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 { collectCallTree } from 'firefox-profiler/profile-query/formatters/call-tree'; +import type { + ThreadSamplesTopDownResult, + ThreadSamplesBottomUpResult, + SessionContext, + WithContext, +} 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 '../../formatters'; +import type { CallTreeCollectionOptions } from 'firefox-profiler/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 libs = profile.libs; + + const regularCallTree = collectCallTree(callTree, 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 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, 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/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..283acc8723 --- /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('5ms'); + 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/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/profiler-cli/src/test/unit/session.test.ts b/profiler-cli/src/test/unit/session.test.ts new file mode 100644 index 0000000000..984b914ecc --- /dev/null +++ b/profiler-cli/src/test/unit/session.test.ts @@ -0,0 +1,445 @@ +/* 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 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: + * - bin/profiler-cli-test: Basic daemon lifecycle + * - bin/profiler-cli-test-multi: Concurrent sessions + */ + +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, + saveSessionMetadata, + loadSessionMetadata, + setCurrentSession, + getCurrentSessionId, + getCurrentSocketPath, + isProcessRunning, + waitForProcessExit, + cleanupSession, + validateSession, + listSessions, +} from '../../session'; +import type { SessionMetadata } from '../../protocol'; + +const TEST_BUILD_HASH = 'test-build-hash'; + +describe('profiler-cli session management', function () { + let testSessionDir: string; + const platformDescriptor = Object.getOwnPropertyDescriptor( + process, + 'platform' + ); + + beforeEach(function () { + // Create a unique temp directory for each test + testSessionDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'profiler-cli-test-') + ); + }); + + afterEach(function () { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + + // 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 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:\\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$/ + ); + expect(secondSocketPath).toMatch( + /^\\\\\.\\pipe\\profiler-cli-[0-9a-f]{12}-test123$/ + ); + 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 () { + 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); + expect(firstNamespace).toBe(thirdNamespace); + }); + + 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'; + 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 + setCurrentSession(testSessionDir, 'session1'); + expect(getCurrentSessionId(testSessionDir)).toBe('session1'); + + // Create second session + setCurrentSession(testSessionDir, 'session2'); + expect(getCurrentSessionId(testSessionDir)).toBe('session2'); + }); + + it('getCurrentSocketPath resolves to correct path', function () { + const sessionId = 'test123'; + const socketPath = getSocketPath(testSessionDir, sessionId); + 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); + }); + + 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 () { + it('removes socket and metadata files', function () { + const sessionId = 'test123'; + const socketPath = getSocketPath(testSessionDir, sessionId); + const metadataPath = getMetadataPath(testSessionDir, sessionId); + + fs.writeFileSync(metadataPath, '{}'); + if (process.platform !== 'win32') { + fs.writeFileSync(socketPath, ''); + } + + 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'; + 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 + 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); + + 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, + 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); + if (process.platform !== 'win32') { + 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/call-tree.test.ts b/src/test/unit/profile-query/call-tree.test.ts new file mode 100644 index 0000000000..41dbbf43b1 --- /dev/null +++ b/src/test/unit/profile-query/call-tree.test.ts @@ -0,0 +1,468 @@ +/* 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 { 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 libs = profile.libs; + + // Collect with budget of 3 nodes + const result = collectCallTree(callTree, 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 libs = profile.libs; + + // With small budget, should still include D (100% at depth 3) + const result = collectCallTree(callTree, 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 libs = profile.libs; + + // With budget of 4: should get A, B (50%), D (50%), C (50%) + const result = collectCallTree(callTree, 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 libs = profile.libs; + + // With budget of 2: A and B, should show C/D/E as elided + const result = collectCallTree(callTree, 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 libs = profile.libs; + + const result = collectCallTree(callTree, 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 libs = profile.libs; + + 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; + 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 libs = profile.libs; + + const result = collectCallTree(callTree, 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 libs = profile.libs; + + // Small budget to force truncation + const result = collectCallTree(callTree, 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 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, 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 libs = profile.libs; + + const result = collectCallTree(callTree, 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 libs = profile.libs; + + 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 + // 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 libs = profile.libs; + + const result = collectCallTree(callTree, 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 libs = profile.libs; + + // Budget that includes A and B, but not the other children + const result = collectCallTree(callTree, 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 libs = profile.libs; + + const result = collectCallTree(callTree, 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 libs = profile.libs; + + const result = collectCallTree(callTree, 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/cpu-activity.test.ts b/src/test/unit/profile-query/cpu-activity.test.ts new file mode 100644 index 0000000000..5d1b7f8dc0 --- /dev/null +++ b/src/test/unit/profile-query/cpu-activity.test.ts @@ -0,0 +1,48 @@ +/* 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 } 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('returns an empty list when there are no slices', function () { + const tsManager = new TimestampManager({ start: 0, end: 10 }); + + expect(collectSliceTree({ slices: [], time: [] }, tsManager)).toEqual([]); + }); +}); 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..d6a736d7cf --- /dev/null +++ b/src/test/unit/profile-query/function-list.test.ts @@ -0,0 +1,591 @@ +/* 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 { + 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' + ); + + 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'); + + 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' + ); + + 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'); + + 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); + + 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); + + // 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('preserves C++ operator names with unmatched angle/paren brackets', function () { + expect(truncateFunctionName('foo::operator>>(int)', 120)).toBe( + 'foo::operator>>(int)' + ); + expect(truncateFunctionName('foo::operator<<(int)', 120)).toBe( + 'foo::operator<<(int)' + ); + expect(truncateFunctionName('foo::operator->()', 120)).toBe( + 'foo::operator->()' + ); + }); + + 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/marker-utils.test.ts b/src/test/unit/profile-query/marker-utils.test.ts new file mode 100644 index 0000000000..2f416c6352 --- /dev/null +++ b/src/test/unit/profile-query/marker-utils.test.ts @@ -0,0 +1,1082 @@ +/* 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, + collectMarkerInfo, + collectMarkerStack, + collectThreadMarkers, + collectThreadNetwork, +} 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 { getCategories } from 'firefox-profiler/selectors/profile'; +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'; + +import type { Marker } from 'firefox-profiler/types'; + +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('marker-info utility functions', function () { + 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); + }); + }); + + 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, + }), + ]); + }); + + it('reports the raw categoryIndex in byCategory (not recovered by name)', function () { + // Guard against regressions that look up the index via findIndex on + // the category name, which would both be O(n) and collide if two + // categories shared a name. + const profile = getProfileWithMarkers([ + [ + 'DOMEvent', + 0, + 2, + { eventType: 'click', latency: 1 } as Record, + ], + ]); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + const markerMap = new MarkerMap(); + + const result = collectThreadMarkers(store, threadMap, markerMap); + expect(result.byCategory).toHaveLength(1); + const entry = result.byCategory[0]; + expect(typeof entry.categoryIndex).toBe('number'); + expect(entry.categoryIndex).toBeGreaterThanOrEqual(0); + // categoryName must resolve from the same index it reports. + const categories = getCategories(store.getState()); + expect(categories[entry.categoryIndex]?.name).toBe(entry.categoryName); + }); + + it('resolves unique-string field values via the string table when grouping', function () { + // The Log marker schema declares `level` as format: 'unique-string', + // meaning the raw payload value is a string-table index. Grouping must + // resolve it back to the interned string (e.g. "Error") rather than + // returning the numeric index. + const profile = getProfileWithMarkers([ + [ + 'Log', + 0, + null, + { type: 'Log', level: 'Error', message: 'a' } as Record< + string, + unknown + >, + ], + [ + 'Log', + 1, + null, + { type: 'Log', level: 'Error', message: 'b' } as Record< + string, + unknown + >, + ], + [ + 'Log', + 2, + null, + { type: 'Log', level: 'Warning', message: 'c' } as Record< + string, + unknown + >, + ], + ]); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + const markerMap = new MarkerMap(); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { groupBy: 'field:level' } + ); + + expect(result.customGroups).toEqual([ + expect.objectContaining({ groupName: 'Error', count: 2 }), + expect.objectContaining({ groupName: 'Warning', count: 1 }), + ]); + }); + + it('auto-groups by a schema-declared enum-like field (schema-driven)', function () { + // With --auto-group and enough markers of the same name, pick a field + // from the schema (not ad-hoc Object.keys heuristics) whose format is + // enum-like (string / unique-string / integer / pid / tid) to sub-group + // on. DOMEvent's `eventType` is declared `format: 'string'`. + const eventTypes = [ + 'click', + 'mousemove', + 'keydown', + 'focus', + 'blur', + 'input', + ]; + const profile = getProfileWithMarkers( + eventTypes.map( + (eventType, i) => + [ + 'DOMEvent', + i, + i + 1, + { type: 'DOMEvent', eventType, latency: i } as Record< + string, + unknown + >, + ] as [string, number, number, Record] + ) + ); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + const markerMap = new MarkerMap(); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { autoGroup: true } + ); + + const domEventStats = result.byType.find( + (s) => s.markerName === 'DOMEvent' + ); + expect(domEventStats).toBeDefined(); + expect(domEventStats!.subGroupKey).toBe('eventType'); + // 6 distinct values, so every eventType should show up as its own group. + const groupNames = domEventStats!.subGroups!.map((g) => g.groupName); + expect(new Set(groupNames)).toEqual(new Set(eventTypes)); + }); + + it('auto-groups on unique-string fields with resolved string values', function () { + // Log.level is `format: 'unique-string'`; auto-group must resolve the + // string-table index before scoring cardinality, and the resulting sub- + // group names must be the interned strings, not integers. + const levels = ['Error', 'Error', 'Warning', 'Warning', 'Info', 'Debug']; + const profile = getProfileWithMarkers( + levels.map( + (level, i) => + [ + 'Log', + i, + null, + { type: 'Log', level, message: `m${i}` } as Record< + string, + unknown + >, + ] as [string, number, null, Record] + ) + ); + const store = storeWithProfile(profile); + const threadMap = new ThreadMap(); + const markerMap = new MarkerMap(); + + const result = collectThreadMarkers( + store, + threadMap, + markerMap, + undefined, + { autoGroup: true } + ); + + const logStats = result.byType.find((s) => s.markerName === 'Log'); + expect(logStats).toBeDefined(); + expect(logStats!.subGroupKey).toBe('level'); + const groupNames = logStats!.subGroups!.map((g) => g.groupName); + // Must be interned strings, not integer indices. + expect(new Set(groupNames)).toEqual( + new Set(['Error', 'Warning', 'Info', 'Debug']) + ); + }); + }); +}); + +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); + 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); + + const hiddenField = result.fields?.find((f) => f.key === 'hiddenString'); + expect(hiddenField).toBeUndefined(); + }); +}); + +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('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'); + 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('collectMarkerStack', function () { + it('returns null stack for a marker without a cause', 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('returns stack frames for a marker with a cause stack', function () { + const { profile } = getProfileFromTextSamples(` + rootFunc + leafFunc + `); + const thread = profile.threads[0]; + const stackIndex = thread.samples.stack[0]; + + if (stackIndex === null || stackIndex === undefined) { + throw new Error('Expected a non-null stack index from text samples'); + } + + 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); + const handle = markerMap.handleForMarker(new Set([0]), 0); + + const result = collectMarkerStack(store, markerMap, threadMap, handle); + + expect(result.stack).not.toBeNull(); + expect(result.stack!.frames.length).toBeGreaterThan(0); + // Leaf frame first + expect(result.stack!.frames[0].name).toBe('leafFunc'); + }); +}); + +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 () { + 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); + // All 3 counted in summary, not just the 2 returned + expect(result.summary.cacheUnknown).toBe(3); + }); + + 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 }, + ]); + + const result = collectThreadNetwork(store, threadMap); + + expect(result.summary.cacheHit).toBe(3); + expect(result.summary.cacheMiss).toBe(2); + expect(result.summary.cacheUnknown).toBe(1); + }); + + it('extracts phase timings per request', function () { + const { store, threadMap } = setupWithNetworkMarkers([ + { + id: 1, + startTime: 0, + fetchStart: 0, + endTime: 100, + payload: { + domainLookupStart: 0, + domainLookupEnd: 5, + connectStart: 5, + tcpConnectEnd: 15, + requestStart: 20, + responseStart: 50, + responseEnd: 80, + }, + }, + ]); + + 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, + }, + }, + ]); + + 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, + 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 }, + }, + { + id: 2, + startTime: 21, + fetchStart: 21, + endTime: 41, + payload: { requestStart: 21, responseStart: 33 }, + }, + ]); + + const result = collectThreadNetwork(store, threadMap); + + expect(result.summary.phaseTotals.ttfb).toBe(20); + }); + + 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); + }); +}); 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..5070144322 --- /dev/null +++ b/src/test/unit/profile-query/process-thread-list.test.ts @@ -0,0 +1,436 @@ +/* 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, tid: threadIndex, 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('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[] = []; + 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-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]); + }); + }); +}); 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..185328c970 --- /dev/null +++ b/src/test/unit/profile-query/profile-querier.test.ts @@ -0,0 +1,369 @@ +/* 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/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 + * 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); + }); + }); + + 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(` + 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', + ]); + }); + }); +}); 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 dfd364dbf3c8eb56b578e184a1acd405eb6a121a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 27 Apr 2026 11:47:52 +0200 Subject: [PATCH 8/8] Improve the "function annotate" command --- src/profile-logic/profile-data.ts | 26 ++ src/profile-query/function-annotate.ts | 562 +++++++++++------------ src/profile-query/types.ts | 10 + src/selectors/per-thread/stack-sample.ts | 1 + 4 files changed, 313 insertions(+), 286 deletions(-) diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 28cfba5169..80e3395805 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -4359,6 +4359,32 @@ export function getNativeSymbolsForCallNode( return set; } +/** + * Return all native symbols whose frames are associated with the given function, + * across all call paths and all occurrences of the function in the call tree. + * + * This is the function-level counterpart to getNativeSymbolsForCallNode, which + * operates on a single call node (one specific path through the call tree). + * Use this when you need assembly coverage for an entire function regardless of + * how it was called, and getNativeSymbolsForCallNode when you need it for a + * specific call site. + */ +export function getNativeSymbolsForFunc( + funcIndex: IndexIntoFuncTable, + frameTable: FrameTable +): Set { + const set = new Set(); + for (let frameIndex = 0; frameIndex < frameTable.func.length; frameIndex++) { + if (frameTable.func[frameIndex] === funcIndex) { + const nativeSymbol = frameTable.nativeSymbol[frameIndex]; + if (nativeSymbol !== null) { + set.add(nativeSymbol); + } + } + } + return set; +} + /** * Return the total of the sample weights per native symbol, by * accumulating the weight from samples which contribute to the diff --git a/src/profile-query/function-annotate.ts b/src/profile-query/function-annotate.ts index ad0b205368..4a94d96f26 100644 --- a/src/profile-query/function-annotate.ts +++ b/src/profile-query/function-annotate.ts @@ -6,6 +6,7 @@ import { getProfile } from 'firefox-profiler/selectors/profile'; import { getSelectedThreadIndexes } from 'firefox-profiler/selectors/url-state'; import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { parseFunctionHandle } from './function-map'; +import { getLibForFunc } from './function-list'; import type { ThreadMap } from './thread-map'; import { getStackLineInfo, @@ -15,15 +16,26 @@ import { getStackAddressInfo, getAddressTimings, } from 'firefox-profiler/profile-logic/address-timings'; +import { + getNativeSymbolInfo, + getNativeSymbolsForFunc, + findAddressProofForFile, +} from 'firefox-profiler/profile-logic/profile-data'; 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 } from 'firefox-profiler/types'; +import type { + Profile, + IndexIntoFuncTable, + IndexIntoNativeSymbolTable, + Thread, +} from 'firefox-profiler/types'; import type { FunctionAnnotateResult, AnnotateMode, - FunctionSourceAnnotation, FunctionAsmAnnotation, + SourceAnnotationResult, + AsmAnnotationsResult, } from './types'; import type { Store } from '../types/store'; @@ -48,333 +60,311 @@ class NodeExternalCommunicationDelegate implements ExternalCommunicationDelegate const nodeDelegate = new NodeExternalCommunicationDelegate(); -export async function functionAnnotate( - store: Store, - threadMap: ThreadMap, - archiveCache: Map>, +async function fetchSourceAnnotation( + funcIndex: IndexIntoFuncTable, functionHandle: string, mode: AnnotateMode, + thread: Thread, + profile: Profile, symbolServerUrl: string, + archiveCache: Map>, contextOption: string -): Promise { - const state = store.getState(); - const profile = getProfile(state); - const { funcTable, stringArray, resourceTable } = profile.shared; - - const funcIndex = parseFunctionHandle(functionHandle, funcTable.length); - const funcName = stringArray[funcTable.name[funcIndex]]; +): Promise { 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 sourceIndex = profile.shared.funcTable.source[funcIndex]; + if (sourceIndex === null) { + if (mode === 'src') { + warnings.push( + `Function ${functionHandle} has no source index. Use --mode asm for assembly view.` + ); } + return { annotation: null, warnings }; } - 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, + samples, } = thread; - const samples = thread.samples; - - const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); - const threadHandle = threadMap.handleForThreadIndexes(threadIndexes); + const filename = thread.stringTable.getString( + thread.sources.filename[sourceIndex] + ); + const sourceUuid = thread.sources.id[sourceIndex]; - // Single pass over frameTable to collect everything keyed on funcIndex: - // - frameInFunc: which frames belong to funcIndex - // - nativeSymbolsForFunc: distinct native symbols for this func - // - addressProof: first usable {debugName, breakpadId, address} for /source/v1 - const frameInFunc = new Uint8Array(frameTable.func.length); - const nativeSymbolsForFunc = new Set(); - let addressProof: AddressProof | null = null; - for (let fi = 0; fi < frameTable.func.length; fi++) { - if (frameTable.func[fi] !== funcIndex) { - continue; - } - frameInFunc[fi] = 1; - const ns = frameTable.nativeSymbol[fi]; - if (ns !== null) { - nativeSymbolsForFunc.add(ns); - if (addressProof === 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], - }; - } - } - } - } - // 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; - } - } + const stackLineInfo = getStackLineInfo( + stackTable, + frameTable, + threadFuncTable, + sourceIndex + ); + const { totalLineHits, selfLineHits } = getLineTimings( + stackLineInfo, + samples + ); - let totalSelfSamples = 0; - let totalTotalSamples = 0; + 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; - if (stackContainsFunc[stackIndex] === 1) { - totalTotalSamples += weight; + samplesWithFunction += weight; + if (stackLineInfo.lineSetTable.self[lineSetIndex] !== -1) { + samplesWithLineInfo += weight; + } + } + + const addressProof = findAddressProofForFile(profile, sourceIndex); + + let fileLines: string[] | null = null; + let totalFileLines: number | null = null; + const fetchResult = await fetchSource( + filename, + sourceUuid, + symbolServerUrl, + addressProof, + 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}`); + } + + const annotatedLineNums = new Set([ + ...totalLineHits.keys(), + ...selfLineHits.keys(), + ]); + let linesToShow: Set; + let contextMode: string; + + if (contextOption === 'file') { + linesToShow = new Set(); + const last = totalFileLines ?? Math.max(...annotatedLineNums); + for (let ln = 1; ln <= last; ln++) { + linesToShow.add(ln); } - if (frameInFunc[stackTable.frame[stackIndex]]) { - totalSelfSamples += weight; + contextMode = 'full file'; + } else { + 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`; } - // 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( + const sortedLines = Array.from(linesToShow).sort((a, b) => a - b); + return { + annotation: { + 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, + })), + }, + warnings, + }; +} + +async function fetchAsmAnnotations( + functionHandle: string, + nativeSymbolsForFunc: Set, + thread: Thread, + profile: Profile, + symbolServerUrl: string +): Promise { + const warnings: string[] = []; + + if (nativeSymbolsForFunc.size === 0) { + warnings.push( + `Function ${functionHandle} has no native symbols — may be JS-only or not symbolicated.` + ); + } + + const { + stackTable, + frameTable, + funcTable: threadFuncTable, + samples, + } = thread; + const nativeSymbolCount = nativeSymbolsForFunc.size; + + const results = await Promise.all( + Array.from(nativeSymbolsForFunc).map(async (nsIndex) => { + const nativeSymbolInfo = getNativeSymbolInfo( + nsIndex, + thread.nativeSymbols, + frameTable, + thread.stringTable + ); + const lib = profile.libs[nativeSymbolInfo.libIndex]; + + const stackAddressInfo = getStackAddressInfo( stackTable, frameTable, threadFuncTable, - sourceIndex + nsIndex ); - const { totalLineHits, selfLineHits } = getLineTimings( - stackLineInfo, + const { totalAddressHits, selfAddressHits } = getAddressTimings( + stackAddressInfo, 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; - } - } + let fetchError: string | null = null; + let instructions: FunctionAsmAnnotation['instructions'] = []; + const localWarnings: string[] = []; - // addressProof is built in the single frameTable pass above; it's used - // by fetchSource to query /source/v1 on local symbol servers. - - // 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, - 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}` + try { + const fetchResult = await fetchAssembly( + nativeSymbolInfo, + lib, + symbolServerUrl, + nodeDelegate ); - } - - // 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); - } + 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('; '); + localWarnings.push( + `Assembly fetch failed for ${nativeSymbolInfo.name}: ${fetchError}` + ); } - contextMode = - CONTEXT === 0 ? 'annotated lines only' : `±${CONTEXT} lines context`; + } catch (e) { + fetchError = e instanceof Error ? e.message : String(e); + localWarnings.push( + `Assembly fetch threw for ${nativeSymbolInfo.name}: ${fetchError}` + ); } - 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, - })), + return { + symbolName: nativeSymbolInfo.name, + symbolAddress: nativeSymbolInfo.address, + functionSize: nativeSymbolInfo.functionSizeIsKnown + ? nativeSymbolInfo.functionSize + : null, + fetchError, + instructions, + localWarnings, }; - } 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') { - if (nativeSymbolsForFunc.size === 0) { - warnings.push( - `Function ${functionHandle} has no native symbols — may be JS-only or not symbolicated.` - ); - } + const annotations: FunctionAsmAnnotation[] = []; + results.forEach((r, i) => { + warnings.push(...r.localWarnings); + annotations.push({ + compilationIndex: i + 1, + symbolName: r.symbolName, + symbolAddress: r.symbolAddress, + functionSize: r.functionSize, + nativeSymbolCount, + fetchError: r.fetchError, + instructions: r.instructions, + }); + }); - const nativeSymbolCount = nativeSymbolsForFunc.size; + return { annotations, warnings }; +} - // Fan out fetchAssembly in parallel — each native symbol is an - // independent symbol-server request. - const results = await Promise.all( - Array.from(nativeSymbolsForFunc).map(async (nsIndex) => { - 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]; - - const stackAddressInfo = getStackAddressInfo( - stackTable, - frameTable, - threadFuncTable, - nsIndex - ); - const { totalAddressHits, selfAddressHits } = getAddressTimings( - stackAddressInfo, - samples - ); +export async function functionAnnotate( + store: Store, + threadMap: ThreadMap, + archiveCache: Map>, + functionHandle: string, + mode: AnnotateMode, + symbolServerUrl: string, + contextOption: string +): Promise { + const state = store.getState(); + const profile = getProfile(state); + const { funcTable, stringArray, resourceTable } = profile.shared; - const nativeSymbolInfo = { - name: symbolName, - address: symbolAddress, - functionSize: functionSize ?? 0, - functionSizeIsKnown: functionSize !== null, - libIndex, - }; - - let fetchError: string | null = null; - let instructions: FunctionAsmAnnotation['instructions'] = []; - const localWarnings: string[] = []; - - 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('; '); - localWarnings.push( - `Assembly fetch failed for ${symbolName}: ${fetchError}` - ); - } - } catch (e) { - fetchError = e instanceof Error ? e.message : String(e); - localWarnings.push( - `Assembly fetch threw for ${symbolName}: ${fetchError}` - ); - } + const funcIndex = parseFunctionHandle(functionHandle, funcTable.length); + const funcName = stringArray[funcTable.name[funcIndex]]; - return { - symbolName, - symbolAddress, - functionSize, - fetchError, - instructions, - localWarnings, - }; - }) - ); + const libraryName = getLibForFunc( + funcIndex, + funcTable, + resourceTable, + profile.libs + )?.name; + const fullName = libraryName ? `${libraryName}!${funcName}` : funcName; - results.forEach((r, i) => { - warnings.push(...r.localWarnings); - asmAnnotations.push({ - compilationIndex: i + 1, - symbolName: r.symbolName, - symbolAddress: r.symbolAddress, - functionSize: r.functionSize, - nativeSymbolCount, - fetchError: r.fetchError, - instructions: r.instructions, - }); - }); - } + const threadIndexes = getSelectedThreadIndexes(state); + const threadSelectors = getThreadSelectors(threadIndexes); + const thread = threadSelectors.getFilteredThread(state); + + const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); + const threadHandle = threadMap.handleForThreadIndexes(threadIndexes); + + const nativeSymbolsForFunc = getNativeSymbolsForFunc( + funcIndex, + thread.frameTable + ); + + const { funcSelf, funcTotal } = threadSelectors.getFunctionListTimings(state); + const totalSelfSamples = funcSelf[funcIndex]; + const totalTotalSamples = funcTotal[funcIndex]; + + const srcPromise: Promise = + mode === 'src' || mode === 'all' + ? fetchSourceAnnotation( + funcIndex, + functionHandle, + mode, + thread, + profile, + symbolServerUrl, + archiveCache, + contextOption + ) + : Promise.resolve({ annotation: null, warnings: [] }); + + const asmPromise: Promise = + mode === 'asm' || mode === 'all' + ? fetchAsmAnnotations( + functionHandle, + nativeSymbolsForFunc, + thread, + profile, + symbolServerUrl + ) + : Promise.resolve({ annotations: [], warnings: [] }); + + const [ + { annotation: srcAnnotation, warnings: srcWarnings }, + { annotations: asmAnnotations, warnings: asmWarnings }, + ] = await Promise.all([srcPromise, asmPromise]); return { type: 'function-annotate', @@ -389,6 +379,6 @@ export async function functionAnnotate( mode, srcAnnotation, asmAnnotations, - warnings, + warnings: [...srcWarnings, ...asmWarnings], }; } diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 8a43ee7ed8..5c1dcfd2f4 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -224,6 +224,16 @@ export type FunctionAsmAnnotation = { instructions: AnnotatedInstruction[]; }; +export type SourceAnnotationResult = { + annotation: FunctionSourceAnnotation | null; + warnings: string[]; +}; + +export type AsmAnnotationsResult = { + annotations: FunctionAsmAnnotation[]; + warnings: string[]; +}; + export type FunctionAnnotateResult = { type: 'function-annotate'; functionHandle: string; diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 11f1a282a5..d39239f75b 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -508,6 +508,7 @@ export function getStackAndSampleSelectorsPerThread( getTreeOrderComparatorInFilteredThread, getCallTree, getFunctionListTree, + getFunctionListTimings, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming,