diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index e518623d26..6e8aecb5f0 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -14,6 +14,7 @@ import { getPreviewSelection, getLocalTracksByPid, getThreads, + getStringTable, getLastNonShiftClick, } from 'firefox-profiler/selectors/profile'; import { @@ -21,6 +22,10 @@ import { getThreadSelectorsFromThreadsKey, selectedThreadSelectors, } from 'firefox-profiler/selectors/per-thread'; +import { + getProfileFlowInfo, + getFullMarkerListPerThread, +} from 'firefox-profiler/selectors/flow'; import { getAllCommittedRanges, getImplementationFilter, @@ -73,11 +78,13 @@ import type { TableViewOptions, SelectionContext, BottomBoxInfo, + IndexIntoFlowTable, } from 'firefox-profiler/types'; import { funcHasDirectRecursiveCall, funcHasRecursiveCall, } from '../profile-logic/transforms'; +import { computeMarkerFlows } from '../profile-logic/marker-data'; import { changeStoredProfileNameInDb } from 'firefox-profiler/app-logic/uploaded-profiles-db'; import type { TabSlug } from '../app-logic/tabs-handling'; import type { CallNodeInfo } from '../profile-logic/call-node-info'; @@ -903,6 +910,53 @@ export function showProvidedTracks( }; } +export function showProvidedThreads( + threadsToShow: Set +): ThunkAction { + return (dispatch, getState) => { + const globalTracks = getGlobalTracks(getState()); + const localTracksByPid = getLocalTracksByPid(getState()); + + const globalTracksToShow: Set = new Set(); + const localTracksByPidToShow: Map> = new Map(); + + for (const [globalTrackIndex, globalTrack] of globalTracks.entries()) { + if (globalTrack.type !== 'process') { + continue; + } + const { mainThreadIndex, pid } = globalTrack; + if (mainThreadIndex !== null && threadsToShow.has(mainThreadIndex)) { + globalTracksToShow.add(globalTrackIndex); + } + const localTracks = localTracksByPid.get(pid); + if (localTracks === undefined) { + continue; + } + + for (const [localTrackIndex, localTrack] of localTracks.entries()) { + if (localTrack.type !== 'thread') { + continue; + } + if (threadsToShow.has(localTrack.threadIndex)) { + const localTracksToShow = localTracksByPidToShow.get(pid); + if (localTracksToShow === undefined) { + localTracksByPidToShow.set(pid, new Set([localTrackIndex])); + } else { + localTracksToShow.add(localTrackIndex); + } + globalTracksToShow.add(globalTrackIndex); + } + } + } + + dispatch({ + type: 'SHOW_PROVIDED_TRACKS', + globalTracksToShow, + localTracksByPidToShow, + }); + }; +} + /** * This action makes the tracks that are provided hidden. */ @@ -1594,6 +1648,37 @@ export function changeHoveredMarker( }; } +export function changeActiveFlows(activeFlows: IndexIntoFlowTable[]): Action { + return { + type: 'CHANGE_ACTIVE_FLOWS', + activeFlows, + }; +} + +export function activateFlowsForMarker( + threadIndex: ThreadIndex, + markerIndex: MarkerIndex +): ThunkAction { + console.log('yo'); + return (dispatch, getState) => { + console.log('aha'); + const profileFlowInfo = getProfileFlowInfo(getState()); + const fullMarkerListPerThread = getFullMarkerListPerThread(getState()); + const stringTable = getStringTable(getState()); + console.log('aha2'); + const flows = + computeMarkerFlows( + threadIndex, + markerIndex, + profileFlowInfo, + fullMarkerListPerThread, + stringTable + ) ?? []; + console.log({ flows }); + dispatch(changeActiveFlows(flows)); + }; +} + /** * This action is used when the user right clicks a marker, and is especially * used to display its context menu. diff --git a/src/app-logic/constants.ts b/src/app-logic/constants.ts index be047a25b2..79816e7b02 100644 --- a/src/app-logic/constants.ts +++ b/src/app-logic/constants.ts @@ -121,7 +121,7 @@ export const PROFILER_SERVER_ORIGIN = 'https://api.profiler.firefox.com'; // [1] https://github.com/mstange/profiler-symbol-server/ // This is the default server. -export const SYMBOL_SERVER_URL = 'https://symbolication.services.mozilla.com'; +export const SYMBOL_SERVER_URL = 'https://mozilla.symbols.samplyprofiler.com'; // See the MarkerPhase type for more information. export const INSTANT: MarkerPhase = 0; diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index 60267bbc77..1fd45d1c2e 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -174,6 +174,7 @@ type BaseQuery = { timelineType: string; sourceViewIndex: number; assemblyView: string; + activeFlows: string; }; type CallTreeQuery = BaseQuery & { @@ -366,6 +367,9 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { query = baseQuery as MarkersQueryShape; query.markerSearch = urlState.profileSpecific.markersSearchString || undefined; + query.activeFlows = + encodeUintArrayForUrlComponent(urlState.profileSpecific.activeFlows) || + undefined; break; case 'network-chart': query = baseQuery as NetworkQueryShape; @@ -493,6 +497,8 @@ export function stateFromLocation( implementation = query.implementation; } + const activeFlows = decodeUintArrayFromUrlComponent(query.activeFlows ?? ''); + const transforms: { [key: string]: Transform[] } = {}; if (selectedThreadsKey !== null) { transforms[selectedThreadsKey] = parseTransforms(query.transforms); @@ -563,6 +569,7 @@ export function stateFromLocation( transforms, sourceView, assemblyView, + activeFlows, isBottomBoxOpenPerPanel, timelineType: validateTimelineType(query.timelineType), showJsTracerSummary: query.summary === undefined ? false : true, diff --git a/src/components/app/Details.tsx b/src/components/app/Details.tsx index 643e6c5929..10ebb23edb 100644 --- a/src/components/app/Details.tsx +++ b/src/components/app/Details.tsx @@ -12,7 +12,7 @@ import { LocalizedErrorBoundary } from './ErrorBoundary'; import { ProfileCallTreeView } from 'firefox-profiler/components/calltree/ProfileCallTreeView'; import { MarkerTable } from 'firefox-profiler/components/marker-table'; import { StackChart } from 'firefox-profiler/components/stack-chart/'; -import { MarkerChart } from 'firefox-profiler/components/marker-chart/'; +import { MarkerChartTab } from 'firefox-profiler/components/marker-chart-tab/'; import { NetworkChart } from 'firefox-profiler/components/network-chart/'; import { FlameGraph } from 'firefox-profiler/components/flame-graph/'; import { JsTracer } from 'firefox-profiler/components/js-tracer/'; @@ -124,7 +124,7 @@ class ProfileViewerImpl extends PureComponent { calltree: , 'flame-graph': , 'stack-chart': , - 'marker-chart': , + 'marker-chart': , 'marker-table': , 'network-chart': , 'js-tracer': , diff --git a/src/components/app/DetailsContainer.css b/src/components/app/DetailsContainer.css index f46c2679eb..41b236a392 100644 --- a/src/components/app/DetailsContainer.css +++ b/src/components/app/DetailsContainer.css @@ -1,10 +1,10 @@ -.DetailsContainer .layout-pane > * { +.DetailsContainer > .layout-pane > * { width: 100%; height: 100%; box-sizing: border-box; } -.DetailsContainer .layout-pane:not(.layout-pane-primary) { +.DetailsContainer > .layout-pane:not(.layout-pane-primary) { max-width: 600px; } @@ -15,12 +15,12 @@ position: unset; } -.DetailsContainer .layout-splitter { +.DetailsContainer > .layout-splitter { border-top: 1px solid var(--grey-30); border-left: 1px solid var(--grey-30); background: var(--grey-10); /* Same background as sidebars */ } -.DetailsContainer .layout-splitter:hover { +.DetailsContainer > .layout-splitter:hover { background: var(--grey-30); /* same as the border above */ } diff --git a/src/components/flow-panel/Canvas.tsx b/src/components/flow-panel/Canvas.tsx new file mode 100644 index 0000000000..98a1005b56 --- /dev/null +++ b/src/components/flow-panel/Canvas.tsx @@ -0,0 +1,1101 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// @flow +import { GREY_20, BLUE_60, BLUE_80 } from 'photon-colors'; +import * as React from 'react'; +import memoize from 'memoize-immutable'; +import { + withChartViewport, + type Viewport, +} from 'firefox-profiler/components/shared/chart/Viewport'; +import { ChartCanvas } from 'firefox-profiler/components/shared/chart/Canvas'; +import { TooltipMarker } from 'firefox-profiler/components/tooltip/Marker'; +import { FlowGapTooltip } from 'firefox-profiler/components/tooltip/FlowGap'; +import TextMeasurement from 'firefox-profiler/utils/text-measurement'; +import { bisectionRight } from 'firefox-profiler/utils/bisect'; +import type { + updatePreviewSelection, + changeMouseTimePosition, + changeActiveFlows, +} from 'firefox-profiler/actions/profile-view'; +import { TIMELINE_MARGIN_LEFT } from 'firefox-profiler/app-logic/constants'; +import type { + Milliseconds, + CssPixels, + UnitIntervalOfProfileRange, + Marker, + MarkerIndex, + FlowTiming, + ThreadIndex, + IndexIntoFlowTable, + FlowTimingRow, + FlowTimingArrow, +} from 'firefox-profiler/types'; +import { + ensureExists, + assertExhaustiveCheck, +} from 'firefox-profiler/utils/types'; +import { computeArrowsRelatedToMarker } from 'firefox-profiler/profile-logic/marker-data'; + +import type { + ChartCanvasScale, + ChartCanvasHoverInfo, +} from '../shared/chart/Canvas'; + +import type { WrapFunctionInDispatch } from 'firefox-profiler/utils/connect'; + +type FlowPanelHoverInfo = { + rowIndex: number | null; + flowIndex: IndexIntoFlowTable | null; + hoveredItem: HoveredFlowPanelItem | null; +}; + +type HoveredFlowPanelItem = + | { + type: 'SINGLE_MARKER'; + hoveredMarker: HoveredFlowMarker; + } + | { + type: 'BETWEEN_MARKERS'; + markerBeforeHoveredGapAsIndexIntoFlowTimingRowMarkerTable: number; + markerBeforeHoveredGap: HoveredFlowMarker; + markerAfterHoveredGap: HoveredFlowMarker; + }; + +type HoveredFlowMarker = { + indexInFlowMarkers: number; // index into flows[flowIndex].flowMarkers + threadIndex: ThreadIndex; + markerIndex: MarkerIndex; + flowMarkerIndex: number; +}; + +type OwnProps = { + rangeStart: Milliseconds; + rangeEnd: Milliseconds; + flowTiming: FlowTiming; + rowHeight: CssPixels; + fullMarkerListPerThread: Marker[][]; + markerLabelGetterPerThread: Array<(marker: MarkerIndex) => string>; + updatePreviewSelection: WrapFunctionInDispatch; + changeMouseTimePosition: WrapFunctionInDispatch< + typeof changeMouseTimePosition + >; + changeActiveFlows: WrapFunctionInDispatch; + marginLeft: CssPixels; + marginRight: CssPixels; + shouldDisplayTooltips: () => boolean; +}; + +type Props = OwnProps & { + // Bring in the viewport props from the higher order Viewport component. + readonly viewport: Viewport; +}; + +const TEXT_OFFSET_TOP = 11; +const TEXT_OFFSET_START = 3; +const MARKER_DOT_RADIUS = 0.25; +const LABEL_PADDING = 5; +const MARKER_BORDER_COLOR = '#2c77d1'; + +class FlowPanelCanvasImpl extends React.PureComponent { + _textMeasurement: null | TextMeasurement = null; + + _memoizedGetArrows = memoize((threadIndex, flowMarkerIndex, flowTiming) => + computeArrowsRelatedToMarker(threadIndex, flowMarkerIndex, flowTiming) + ); + + drawCanvas = ( + ctx: CanvasRenderingContext2D, + scale: ChartCanvasScale, + hoverInfo: ChartCanvasHoverInfo + ) => { + const { + rowHeight, + flowTiming, + viewport: { + viewportTop, + viewportBottom, + containerWidth, + containerHeight, + }, + } = this.props; + const { cssToUserScale } = scale; + if (cssToUserScale !== 1) { + throw new Error( + 'StackChartCanvasImpl sets scaleCtxToCssPixels={true}, so canvas user space units should be equal to CSS pixels.' + ); + } + + const { hoveredItem } = hoverInfo; + + // Convert CssPixels to Stack Depth + const rowCount = flowTiming.rows.length; + const startRow = Math.floor(viewportTop / rowHeight); + const endRow = Math.min(Math.ceil(viewportBottom / rowHeight), rowCount); + + // Common properties that won't be changed later. + ctx.lineWidth = 1; + + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, containerWidth, containerHeight); + this.drawRowHighlights(ctx, startRow, endRow); + this.drawRowContents(ctx, null, startRow, endRow, hoveredItem); + this.drawSeparatorsAndLabels(ctx, startRow, endRow); + + const hoveredMarker = + hoveredItem !== null && + hoveredItem.hoveredItem && + hoveredItem.hoveredItem.type === 'SINGLE_MARKER' + ? hoveredItem.hoveredItem.hoveredMarker + : null; + + if (hoveredMarker !== null) { + const { threadIndex, flowMarkerIndex } = hoveredMarker; + if (threadIndex !== null && flowMarkerIndex !== null) { + const arrows = this._memoizedGetArrows( + threadIndex, + flowMarkerIndex, + flowTiming + ); + this.drawArrows(ctx, arrows, startRow, endRow); + } + } + }; + + drawRowHighlights( + ctx: CanvasRenderingContext2D, + startRow: number, + endRow: number + ) { + const { flowTiming } = this.props; + const { rows } = flowTiming; + for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { + const rowType = rows[rowIndex].rowType; + if (rowType === 'ACTIVE') { + this.drawRowHighlight(ctx, rowIndex); + } + } + } + + drawRowHighlight(ctx: CanvasRenderingContext2D, rowIndex: number) { + const { + rowHeight, + viewport: { viewportTop, containerWidth }, + } = this.props; + + ctx.fillStyle = 'rgba(40, 122, 169, 0.2)'; + ctx.fillRect( + 0, // To include the labels also + rowIndex * rowHeight - viewportTop, + containerWidth, + rowHeight - 1 // Subtract 1 for borders. + ); + } + + drawFlowRectangle( + ctx: CanvasRenderingContext2D, + rowIndex: number, + timeAtViewportLeft: number, + timeAtViewportRightPlusMargin: number, + rangeStart: number, + rangeLength: number, + viewportLeft: number, + markerContainerWidth: number, + viewportLength: number, + marginLeft: number + ) { + const { + rowHeight, + flowTiming, + viewport: { viewportTop }, + } = this.props; + const { rows } = flowTiming; + const { devicePixelRatio } = window; + + const row = rows[rowIndex]; + const startTimestamp = row.flowStart; + const endTimestamp = row.flowEnd; + + const y: CssPixels = rowIndex * rowHeight - viewportTop; + const h: CssPixels = rowHeight - 1; + + // Only draw samples that are in bounds. + if ( + !( + endTimestamp >= timeAtViewportLeft && + startTimestamp < timeAtViewportRightPlusMargin + ) + ) { + return; + } + const startTime: UnitIntervalOfProfileRange = + (startTimestamp - rangeStart) / rangeLength; + const endTime: UnitIntervalOfProfileRange = + (endTimestamp - rangeStart) / rangeLength; + + let x: CssPixels = + ((startTime - viewportLeft) * markerContainerWidth) / viewportLength + + marginLeft; + let w: CssPixels = + ((endTime - startTime) * markerContainerWidth) / viewportLength; + + x = Math.round(x * devicePixelRatio) / devicePixelRatio; + w = Math.round(w * devicePixelRatio) / devicePixelRatio; + + ctx.strokeStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.beginPath(); + + // We want the rectangle to have a clear margin, that's why we increment y + // and decrement h (twice, for both margins). + // We also add "0.5" more so that the stroke is properly on a pixel. + // Indeed strokes are drawn on both sides equally, so half a pixel on each + // side in this case. + ctx.rect( + x + 0.5, // + 0.5 for the stroke + y + 1 + 0.5, // + 1 for the top margin, + 0.5 for the stroke + w - 1, // - 1 to account for left and right strokes. + h - 2 - 1 // + 2 accounts for top and bottom margins, + 1 accounts for top and bottom strokes + ); + ctx.stroke(); + } + + // Note: we used a long argument list instead of an object parameter on + // purpose, to reduce GC pressure while drawing. + drawOneMarker( + ctx: CanvasRenderingContext2D, + x: CssPixels, + y: CssPixels, + w: CssPixels, + h: CssPixels, + isInstantMarker: boolean, + markerIndex: MarkerIndex, + threadIndex: number, + isHighlighted: boolean + ) { + if (isInstantMarker) { + w = 1; + } + this.drawOneIntervalMarker( + ctx, + x, + y, + w, + h, + markerIndex, + threadIndex, + isHighlighted + ); + } + + drawOneIntervalMarker( + ctx: CanvasRenderingContext2D, + x: CssPixels, + y: CssPixels, + w: CssPixels, + h: CssPixels, + markerIndex: MarkerIndex, + threadIndex: number, + isHighlighted: boolean + ) { + const { marginLeft, markerLabelGetterPerThread } = this.props; + + if (w <= 2) { + // This is an interval marker small enough that if we drew it as a + // rectangle, we wouldn't see any inside part. With a width of 2 pixels, + // the rectangle-with-borders would only be borders. With less than 2 + // pixels, the borders would collapse. + // So let's draw it directly as a rect. + ctx.fillStyle = isHighlighted ? BLUE_80 : MARKER_BORDER_COLOR; + + // w is rounded in the caller, but let's make sure it's at least 1. + w = Math.max(w, 1); + ctx.fillRect(x, y + 1, w, h - 2); + } else { + // This is a bigger interval marker. + const textMeasurement = this._getTextMeasurement(ctx); + + ctx.fillStyle = isHighlighted ? BLUE_60 : '#8ac4ff'; + ctx.strokeStyle = isHighlighted ? BLUE_80 : MARKER_BORDER_COLOR; + + ctx.beginPath(); + + // We want the rectangle to have a clear margin, that's why we increment y + // and decrement h (twice, for both margins). + // We also add "0.5" more so that the stroke is properly on a pixel. + // Indeed strokes are drawn on both sides equally, so half a pixel on each + // side in this case. + ctx.rect( + x + 0.5, // + 0.5 for the stroke + y + 1 + 0.5, // + 1 for the top margin, + 0.5 for the stroke + w - 1, // - 1 to account for left and right strokes. + h - 2 - 1 // + 2 accounts for top and bottom margins, + 1 accounts for top and bottom strokes + ); + ctx.fill(); + ctx.stroke(); + + // Draw the text label + // TODO - L10N RTL. + // Constrain the x coordinate to the leftmost area. + const x2: CssPixels = + x < marginLeft ? marginLeft + TEXT_OFFSET_START : x + TEXT_OFFSET_START; + const visibleWidth = x < marginLeft ? w - marginLeft + x : w; + const w2: CssPixels = visibleWidth - 2 * TEXT_OFFSET_START; + + if (w2 > textMeasurement.minWidth) { + const fittedText = textMeasurement.getFittedText( + markerLabelGetterPerThread[threadIndex](markerIndex), + w2 + ); + if (fittedText) { + ctx.fillStyle = isHighlighted ? 'white' : 'black'; + ctx.fillText(fittedText, x2, y + TEXT_OFFSET_TOP); + } + } + } + } + /* + // x indicates the center of this marker + // y indicates the top of the row + // h indicates the available height in the row + drawOneInstantMarker( + ctx: CanvasRenderingContext2D, + x: CssPixels, + y: CssPixels, + h: CssPixels, + isHighlighted: boolean + ) { + ctx.fillStyle = isHighlighted ? BLUE_60 : '#8ac4ff'; + ctx.strokeStyle = isHighlighted ? BLUE_80 : MARKER_BORDER_COLOR; + + // We're drawing a diamond shape, whose height is h - 2, and width is h / 2. + ctx.beginPath(); + ctx.moveTo(x - h / 4, y + h / 2); + ctx.lineTo(x, y + 1.5); + ctx.lineTo(x + h / 4, y + h / 2); + ctx.lineTo(x, y + h - 1.5); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } +*/ + drawMarkersForRow( + ctx: CanvasRenderingContext2D, + rowIndex: number, + flowTimingRow: FlowTimingRow, + timeAtViewportLeft: number, + timeAtViewportRightPlusMargin: number, + rangeStart: Milliseconds, + rangeLength: Milliseconds, + viewportLeft: CssPixels, + viewportLength: CssPixels, + rowHeight: CssPixels, + viewportTop: CssPixels, + markerContainerWidth: CssPixels, + marginLeft: CssPixels, + hoveredMarker: HoveredFlowMarker | null + ) { + const { devicePixelRatio } = window; + + const { markers } = flowTimingRow; + + const y: CssPixels = rowIndex * rowHeight - viewportTop; + const h: CssPixels = rowHeight - 1; + + // Track the last drawn marker X position, so that we can avoid overdrawing. + let previousMarkerDrawnAtX: number | null = null; + + for (let i = 0; i < markers.length; i++) { + const startTimestamp = markers.startTime[i]; + const endTimestamp = markers.endTime[i]; + const isInstantMarker = markers.isInstant[i] === 1; + + // Only draw samples that are in bounds. + if ( + endTimestamp >= timeAtViewportLeft && + startTimestamp < timeAtViewportRightPlusMargin + ) { + const startTime: UnitIntervalOfProfileRange = + (startTimestamp - rangeStart) / rangeLength; + const endTime: UnitIntervalOfProfileRange = + (endTimestamp - rangeStart) / rangeLength; + + let x: CssPixels = + ((startTime - viewportLeft) * markerContainerWidth) / viewportLength + + marginLeft; + let w: CssPixels = + ((endTime - startTime) * markerContainerWidth) / viewportLength; + + x = Math.round(x * devicePixelRatio) / devicePixelRatio; + w = Math.round(w * devicePixelRatio) / devicePixelRatio; + + const markerIndex = markers.markerIndex[i]; + const threadIndex = markers.threadIndex[i]; + const isHovered = + hoveredMarker !== null && + threadIndex === hoveredMarker.threadIndex && + markerIndex === hoveredMarker.markerIndex; + + if ( + isInstantMarker || + // Always render non-dot markers and markers that are larger than + // one pixel. + w > 1 || + // Do not render dot markers that occupy the same pixel, as this can take + // a lot of time, and not change the visual display of the chart. + x !== previousMarkerDrawnAtX + ) { + previousMarkerDrawnAtX = x; + this.drawOneMarker( + ctx, + x, + y, + w, + h, + isInstantMarker, + markerIndex, + threadIndex, + isHovered + ); + } + } + } + } + + drawHoveredGapIndicator( + ctx: CanvasRenderingContext2D, + rowIndex: number, + flowTimingRow: FlowTimingRow, + timeAtViewportLeft: number, + timeAtViewportRightPlusMargin: number, + rangeStart: Milliseconds, + rangeLength: Milliseconds, + viewportLeft: CssPixels, + viewportLength: CssPixels, + rowHeight: CssPixels, + viewportTop: CssPixels, + markerContainerWidth: CssPixels, + marginLeft: CssPixels, + markerBeforeHoveredGapAsIndexIntoFlowTimingRowMarkerTable: number + ) { + const { devicePixelRatio } = window; + + const { markers } = flowTimingRow; + + const y: CssPixels = rowIndex * rowHeight - viewportTop; + const h: CssPixels = rowHeight - 1; + + const gapStart = + markers.endTime[ + markerBeforeHoveredGapAsIndexIntoFlowTimingRowMarkerTable + ]; + const gapEnd = + markers.startTime[ + markerBeforeHoveredGapAsIndexIntoFlowTimingRowMarkerTable + 1 + ]; + + // Only draw gap indicators that are in bounds. + if ( + gapEnd >= timeAtViewportLeft && + gapStart < timeAtViewportRightPlusMargin + ) { + const startTime: UnitIntervalOfProfileRange = + (gapStart - rangeStart) / rangeLength; + const endTime: UnitIntervalOfProfileRange = + (gapEnd - rangeStart) / rangeLength; + + let x: CssPixels = + ((startTime - viewportLeft) * markerContainerWidth) / viewportLength + + marginLeft; + let w: CssPixels = + ((endTime - startTime) * markerContainerWidth) / viewportLength; + + x = Math.round(x * devicePixelRatio) / devicePixelRatio; + w = Math.round(w * devicePixelRatio) / devicePixelRatio; + + if (w > 1) { + ctx.fillStyle = '#666'; + ctx.fillRect(x, y + 1 + 4, w, h - 2 - 4 * 2); + } + } + } + + drawRowContents( + ctx: CanvasRenderingContext2D, + hoveredItem: MarkerIndex | null, + startRow: number, + endRow: number, + hoverInfo: FlowPanelHoverInfo | null + ) { + const { + rangeStart, + rangeEnd, + flowTiming, + rowHeight, + marginLeft, + marginRight, + viewport: { + containerWidth, + containerHeight, + viewportLeft, + viewportRight, + viewportTop, + }, + } = this.props; + + const markerContainerWidth = containerWidth - marginLeft - marginRight; + + const rangeLength: Milliseconds = rangeEnd - rangeStart; + const viewportLength: UnitIntervalOfProfileRange = + viewportRight - viewportLeft; + + // Decide which samples to actually draw + const timeAtViewportLeft: Milliseconds = + rangeStart + rangeLength * viewportLeft; + const timeAtViewportRightPlusMargin: Milliseconds = + rangeStart + + rangeLength * viewportRight + + // This represents the amount of seconds in the right margin: + marginRight * ((viewportLength * rangeLength) / markerContainerWidth); + + // We'll restore the context at the end, so that the clip region will be + // removed. + ctx.save(); + // The clip operation forbids drawing in the label zone. + ctx.beginPath(); + ctx.rect(marginLeft, 0, markerContainerWidth, containerHeight); + ctx.clip(); + + // Only draw the stack frames that are vertically within view. + for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { + // Get the timing information for a row of stack frames. + const flowTimingRow = flowTiming.rows[rowIndex]; + const flowIndex = flowTimingRow.flowIndex; + this.drawFlowRectangle( + ctx, + rowIndex, + timeAtViewportLeft, + timeAtViewportRightPlusMargin, + rangeStart, + rangeLength, + viewportLeft, + markerContainerWidth, + viewportLength, + marginLeft + ); + const hoveredItemInThisRow = + hoverInfo !== null && + hoverInfo.flowIndex === flowIndex && + hoverInfo.hoveredItem !== null + ? hoverInfo.hoveredItem + : null; + const hoveredMarkerInThisRow = + hoveredItemInThisRow !== null && + hoveredItemInThisRow.type === 'SINGLE_MARKER' + ? hoveredItemInThisRow.hoveredMarker + : null; + this.drawMarkersForRow( + ctx, + rowIndex, + flowTimingRow, + timeAtViewportLeft, + timeAtViewportRightPlusMargin, + rangeStart, + rangeLength, + viewportLeft, + viewportLength, + rowHeight, + viewportTop, + markerContainerWidth, + marginLeft, + hoveredMarkerInThisRow + ); + if ( + hoveredItemInThisRow !== null && + hoveredItemInThisRow.type === 'BETWEEN_MARKERS' + ) { + this.drawHoveredGapIndicator( + ctx, + rowIndex, + flowTimingRow, + timeAtViewportLeft, + timeAtViewportRightPlusMargin, + rangeStart, + rangeLength, + viewportLeft, + viewportLength, + rowHeight, + viewportTop, + markerContainerWidth, + marginLeft, + hoveredItemInThisRow.markerBeforeHoveredGapAsIndexIntoFlowTimingRowMarkerTable + ); + } + } + + ctx.restore(); + } + + /** + * Lazily create the text measurement tool, as a valid 2d rendering context must + * exist before it is created. + */ + _getTextMeasurement(ctx: CanvasRenderingContext2D): TextMeasurement { + if (!this._textMeasurement) { + this._textMeasurement = new TextMeasurement(ctx); + } + return this._textMeasurement; + } + + drawSeparatorsAndLabels( + ctx: CanvasRenderingContext2D, + startRow: number, + endRow: number + ) { + const { + flowTiming, + rowHeight, + marginLeft, + marginRight, + viewport: { viewportTop, containerWidth, containerHeight }, + } = this.props; + + const usefulContainerWidth = containerWidth - marginRight; + + // Draw separators + ctx.fillStyle = GREY_20; + ctx.fillRect(marginLeft - 1, 0, 1, containerHeight); + for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { + // `- 1` at the end, because the top separator is not drawn in the canvas, + // it's drawn using CSS' border property. And canvas positioning is 0-based. + const y = (rowIndex + 1) * rowHeight - viewportTop - 1; + ctx.fillRect(0, y, usefulContainerWidth, 1); + } + + const textMeasurement = this._getTextMeasurement(ctx); + + // Draw the marker names in the left margin. + ctx.fillStyle = '#000000'; + for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { + const markerTimingRow = flowTiming.rows[rowIndex]; + // Draw the marker name. + const { label } = markerTimingRow; + + const y = rowIndex * rowHeight - viewportTop; + + // Even when it's on active tab view, have a hard cap on the text length. + const fittedText = textMeasurement.getFittedText( + label, + TIMELINE_MARGIN_LEFT - LABEL_PADDING + ); + + ctx.fillText(fittedText, LABEL_PADDING, y + TEXT_OFFSET_TOP); + } + } + + drawArrows( + ctx: CanvasRenderingContext2D, + arrows: FlowTimingArrow[], + startRow: number, + endRow: number + ) { + const { + rangeStart, + rangeEnd, + rowHeight, + marginLeft, + marginRight, + viewport: { + containerWidth, + containerHeight, + viewportLeft, + viewportRight, + viewportTop, + }, + } = this.props; + + const markerContainerWidth = containerWidth - marginLeft - marginRight; + + const rangeLength: Milliseconds = rangeEnd - rangeStart; + const viewportLength: UnitIntervalOfProfileRange = + viewportRight - viewportLeft; + + // Decide which samples to actually draw + const timeAtViewportLeft: Milliseconds = + rangeStart + rangeLength * viewportLeft; + const timeAtViewportRightPlusMargin: Milliseconds = + rangeStart + + rangeLength * viewportRight + + // This represents the amount of seconds in the right margin: + marginRight * ((viewportLength * rangeLength) / markerContainerWidth); + + // We'll restore the context at the end, so that the clip region will be + // removed. + ctx.save(); + // The clip operation forbids drawing in the label zone. + ctx.beginPath(); + ctx.rect(marginLeft, 0, markerContainerWidth, containerHeight); + ctx.clip(); + + ctx.lineCap = 'round'; + ctx.strokeStyle = 'black'; + + for (const arrow of arrows) { + const { time, rowIndexesFrom, rowIndexesTo, minRowIndex, maxRowIndex } = + arrow; + if ( + maxRowIndex < startRow || + minRowIndex > endRow || + time < timeAtViewportLeft || + time > timeAtViewportRightPlusMargin + ) { + continue; + } + const minY: CssPixels = + minRowIndex * rowHeight - viewportTop + rowHeight / 2 + 2; + const maxY: CssPixels = + maxRowIndex * rowHeight - viewportTop + rowHeight / 2 - 2; + const timeAsUnit: UnitIntervalOfProfileRange = + (time - rangeStart) / rangeLength; + const x: CssPixels = + ((timeAsUnit - viewportLeft) * markerContainerWidth) / viewportLength + + marginLeft; + + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, minY); + ctx.lineTo(x, maxY); + ctx.stroke(); + + const minFrom = Math.min(...rowIndexesFrom); + const maxFrom = Math.max(...rowIndexesFrom); + for (const rowIndex of rowIndexesFrom) { + const y = rowIndex * rowHeight - viewportTop + rowHeight / 2; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x, y); + ctx.stroke(); + } + for (const rowIndex of rowIndexesTo) { + const y = rowIndex * rowHeight - viewportTop + rowHeight / 2; + if (minFrom < rowIndex) { + // Draw arrow from top to bottom (aimed at rowIndex) + ctx.beginPath(); + ctx.moveTo(x - 3.5, y - 8); + ctx.lineTo(x + 3.5, y - 8); + ctx.lineTo(x, y); + ctx.closePath(); + ctx.fill(); + } + if (maxFrom > rowIndex) { + // Draw arrow from bottom to top (aimed at rowIndex) + ctx.beginPath(); + ctx.moveTo(x + 3.5, y + 8); + ctx.lineTo(x - 3.5, y + 8); + ctx.lineTo(x, y); + ctx.closePath(); + ctx.fill(); + } + } + } + ctx.restore(); + } + + hitTest = (x: CssPixels, y: CssPixels): FlowPanelHoverInfo | null => { + const { + rangeStart, + rangeEnd, + flowTiming, + rowHeight, + marginLeft, + marginRight, + viewport: { viewportLeft, viewportRight, viewportTop, containerWidth }, + } = this.props; + + // Note: we may want to increase this value to hit markers that are farther. + const dotRadius: CssPixels = MARKER_DOT_RADIUS * rowHeight; + if (x < marginLeft - dotRadius) { + return null; + } + + const markerContainerWidth = containerWidth - marginLeft - marginRight; + + const rangeLength: Milliseconds = rangeEnd - rangeStart; + + // Reminder: this is a value between 0 and 1, and represents a percentage of + // the full time range. + const viewportLength: UnitIntervalOfProfileRange = + viewportRight - viewportLeft; + + // This is the x position in terms of unit interval (so, between 0 and 1). + const xInUnitInterval: UnitIntervalOfProfileRange = + viewportLeft + viewportLength * ((x - marginLeft) / markerContainerWidth); + + const dotRadiusInTime = + (dotRadius / markerContainerWidth) * viewportLength * rangeLength; + + const xInTime: Milliseconds = rangeStart + xInUnitInterval * rangeLength; + const rowIndex = Math.floor((y + viewportTop) / rowHeight); + + if (rowIndex < 0 || rowIndex >= flowTiming.rows.length) { + return null; + } + + const row = flowTiming.rows[rowIndex]; + const flowIndex = row.flowIndex; + const markerTiming = row.markers; + + if ( + !markerTiming || + typeof markerTiming === 'string' || + !markerTiming.length + ) { + return null; + } + + // This is a small utility function to define if some marker timing is in + // our hit test range. + const isMarkerTimingInDotRadius = (index: number) => + markerTiming.startTime[index] < xInTime + dotRadiusInTime && + markerTiming.endTime[index] > xInTime - dotRadiusInTime; + + // A markerTiming line is ordered. + // 1. Let's find a marker reasonably close to our mouse cursor. + // The result of this bisection gives the first marker that starts _after_ + // our mouse cursor. Our result will be either this marker, or the previous + // one. + const nextStartIndex = bisectionRight(markerTiming.startTime, xInTime); + + if (nextStartIndex > 0 && nextStartIndex < markerTiming.length) { + // 2. This is the common case: 2 markers are candidates. Then we measure + // the distance between them and the mouse cursor and chose the smallest + // distance. + const prevStartIndex = nextStartIndex - 1; + + // Note that these values can be negative if the cursor is _inside_ a + // marker. There should be one at most in this case, and we'll want it. So + // NO Math.abs here. + const distanceToNext = markerTiming.startTime[nextStartIndex] - xInTime; + const distanceToPrev = xInTime - markerTiming.endTime[prevStartIndex]; + + const closest = + distanceToPrev < distanceToNext ? prevStartIndex : nextStartIndex; + + // 3. When we found the closest, we still have to check if it's in close + // enough! + if (isMarkerTimingInDotRadius(closest)) { + return { + rowIndex, + flowIndex, + hoveredItem: { + type: 'SINGLE_MARKER', + hoveredMarker: { + markerIndex: markerTiming.markerIndex[closest], + flowMarkerIndex: markerTiming.flowMarkerIndex[closest], + threadIndex: markerTiming.threadIndex[closest], + indexInFlowMarkers: closest, + }, + }, + }; + } + + // The cursor is between two markers. + return { + rowIndex, + flowIndex, + hoveredItem: { + type: 'BETWEEN_MARKERS', + markerBeforeHoveredGapAsIndexIntoFlowTimingRowMarkerTable: + prevStartIndex, + markerBeforeHoveredGap: { + markerIndex: markerTiming.markerIndex[prevStartIndex], + flowMarkerIndex: markerTiming.flowMarkerIndex[prevStartIndex], + threadIndex: markerTiming.threadIndex[prevStartIndex], + indexInFlowMarkers: prevStartIndex, + }, + markerAfterHoveredGap: { + markerIndex: markerTiming.markerIndex[nextStartIndex], + flowMarkerIndex: markerTiming.flowMarkerIndex[nextStartIndex], + threadIndex: markerTiming.threadIndex[nextStartIndex], + indexInFlowMarkers: nextStartIndex, + }, + }, + }; + } else if (nextStartIndex === 0) { + // 4. Special case 1: the mouse cursor is at the left of all markers in + // this line. Then, we have only 1 candidate, we can check if it's inside + // our hit test range right away. + if (isMarkerTimingInDotRadius(nextStartIndex)) { + return { + rowIndex, + flowIndex, + hoveredItem: { + type: 'SINGLE_MARKER', + hoveredMarker: { + markerIndex: markerTiming.markerIndex[nextStartIndex], + flowMarkerIndex: markerTiming.flowMarkerIndex[nextStartIndex], + threadIndex: markerTiming.threadIndex[nextStartIndex], + indexInFlowMarkers: nextStartIndex, + }, + }, + }; + } + } else { + // 5. Special case 2: the mouse cursor is at the right of all markers in + // this line. Then we only have 1 candidate as well, let's check if it's + // inside our hit test range. + if (isMarkerTimingInDotRadius(nextStartIndex - 1)) { + return { + rowIndex, + flowIndex, + hoveredItem: { + type: 'SINGLE_MARKER', + hoveredMarker: { + markerIndex: markerTiming.markerIndex[nextStartIndex - 1], + flowMarkerIndex: markerTiming.flowMarkerIndex[nextStartIndex - 1], + threadIndex: markerTiming.threadIndex[nextStartIndex - 1], + indexInFlowMarkers: nextStartIndex - 1, + }, + }, + }; + } + } + + return { + rowIndex, + flowIndex, + hoveredItem: null, + }; + }; + + onMouseMove = (event: { nativeEvent: MouseEvent }) => { + const { + changeMouseTimePosition, + rangeStart, + rangeEnd, + marginLeft, + marginRight, + viewport: { viewportLeft, viewportRight, containerWidth }, + } = this.props; + const viewportLength: UnitIntervalOfProfileRange = + viewportRight - viewportLeft; + const markerContainerWidth = containerWidth - marginLeft - marginRight; + // This is the x position in terms of unit interval (so, between 0 and 1). + const xInUnitInterval: UnitIntervalOfProfileRange = + viewportLeft + + viewportLength * + ((event.nativeEvent.offsetX - marginLeft) / markerContainerWidth); + + if (xInUnitInterval < 0 || xInUnitInterval > 1) { + changeMouseTimePosition(null); + } else { + const rangeLength: Milliseconds = rangeEnd - rangeStart; + const xInTime: Milliseconds = rangeStart + xInUnitInterval * rangeLength; + changeMouseTimePosition(xInTime); + } + }; + + onMouseLeave = () => { + this.props.changeMouseTimePosition(null); + }; + + onDoubleClickMarker = (_hoveredItems: FlowPanelHoverInfo | null) => {}; + + onSelectItem = (hoveredItems: FlowPanelHoverInfo | null) => { + const flowIndex = hoveredItems === null ? null : hoveredItems.flowIndex; + if (flowIndex === null) { + return; + } + + const { changeActiveFlows } = this.props; + changeActiveFlows([flowIndex]); + }; + + onRightClickMarker = (_hoveredItems: FlowPanelHoverInfo | null) => { + // const markerIndex = hoveredItems === null ? null : hoveredItems.markerIndex; + // const { changeRightClickedMarker, threadsKey } = this.props; + // changeRightClickedMarker(threadsKey, markerIndex); + }; + + getHoveredMarkerInfo = (hoverInfo: FlowPanelHoverInfo): React.ReactNode => { + if (!this.props.shouldDisplayTooltips() || hoverInfo.hoveredItem === null) { + return null; + } + + const { hoveredItem } = hoverInfo; + + switch (hoveredItem.type) { + case 'SINGLE_MARKER': { + const { threadIndex, markerIndex } = hoveredItem.hoveredMarker; + + const marker = ensureExists( + this.props.fullMarkerListPerThread[threadIndex][markerIndex] + ); + return ( + + ); + } + case 'BETWEEN_MARKERS': { + const { markerBeforeHoveredGap, markerAfterHoveredGap } = hoveredItem; + const beforeGapMarker = ensureExists( + this.props.fullMarkerListPerThread[ + markerBeforeHoveredGap.threadIndex + ][markerBeforeHoveredGap.markerIndex] + ); + const afterGapMarker = ensureExists( + this.props.fullMarkerListPerThread[markerAfterHoveredGap.threadIndex][ + markerAfterHoveredGap.markerIndex + ] + ); + return ( + + ); + } + default: { + throw assertExhaustiveCheck( + hoveredItem, + 'Unhandled HoveredFlowPanelItem type.' + ); + } + } + }; + + override render() { + const { containerWidth, containerHeight, isDragging } = this.props.viewport; + + return ( + + ); + } +} + +export const FlowPanelCanvas = withChartViewport(FlowPanelCanvasImpl); diff --git a/src/components/flow-panel/index.css b/src/components/flow-panel/index.css new file mode 100644 index 0000000000..7451d9d019 --- /dev/null +++ b/src/components/flow-panel/index.css @@ -0,0 +1,13 @@ +/* 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/. */ + +.flowPanel { + display: flex; + flex: 1; + flex-flow: column nowrap; +} + +.markerChartCanvas { + border-top: 1px solid var(--grey-30); +} diff --git a/src/components/flow-panel/index.tsx b/src/components/flow-panel/index.tsx new file mode 100644 index 0000000000..593d367411 --- /dev/null +++ b/src/components/flow-panel/index.tsx @@ -0,0 +1,179 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// @flow +import * as React from 'react'; +import { + TIMELINE_MARGIN_LEFT, + TIMELINE_MARGIN_RIGHT, +} from 'firefox-profiler/app-logic/constants'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { FlowPanelCanvas } from './Canvas'; + +import { + getCommittedRange, + getPreviewSelection, +} from 'firefox-profiler/selectors/profile'; +import { + getFullMarkerListPerThread, + getMarkerChartLabelGetterPerThread, + getFlowTiming, +} from 'firefox-profiler/selectors/flow'; +import { + updatePreviewSelection, + changeMouseTimePosition, + changeActiveFlows, +} from 'firefox-profiler/actions/profile-view'; +import { ContextMenuTrigger } from 'firefox-profiler/components/shared/ContextMenuTrigger'; + +import type { + Marker, + MarkerIndex, + FlowTiming, + UnitIntervalOfProfileRange, + StartEndRange, + PreviewSelection, +} from 'firefox-profiler/types'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './index.css'; + +const ROW_HEIGHT = 16; + +type DispatchProps = { + updatePreviewSelection: typeof updatePreviewSelection; + changeMouseTimePosition: typeof changeMouseTimePosition; + changeActiveFlows: typeof changeActiveFlows; +}; + +type StateProps = { + fullMarkerListPerThread: Marker[][]; + markerLabelGetterPerThread: Array<(marker: MarkerIndex) => string>; + flowTiming: FlowTiming; + timeRange: StartEndRange; + previewSelection: PreviewSelection; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class FlowPanelImpl extends React.PureComponent { + _viewport: HTMLDivElement | null = null; + + /** + * Determine the maximum zoom of the viewport. + */ + getMaximumZoom(): UnitIntervalOfProfileRange { + const { + timeRange: { start, end }, + } = this.props; + + // This is set to a very small value, that represents 1ns. We can't set it + // to zero unless we revamp how ranges are handled in the app to prevent + // less-than-1ns ranges, otherwise we can get stuck at a "0" zoom. + const ONE_NS = 1e-6; + return ONE_NS / (end - start); + } + + _shouldDisplayTooltips = () => true; + + _takeViewportRef = (viewport: HTMLDivElement | null) => { + this._viewport = viewport; + }; + + _focusViewport = () => { + if (this._viewport) { + this._viewport.focus(); + } + }; + + override componentDidMount() { + this._focusViewport(); + } + + override render() { + const { + timeRange, + flowTiming, + fullMarkerListPerThread, + markerLabelGetterPerThread, + previewSelection, + updatePreviewSelection, + changeMouseTimePosition, + changeActiveFlows, + } = this.props; + + // The viewport needs to know about the height of what it's drawing, calculate + // that here at the top level component. + const rowCount = flowTiming.rows.length; + const maxViewportHeight = rowCount * ROW_HEIGHT; + + return ( +
+ {rowCount === 0 ? null : ( + + + + )} +
+ ); + } +} + +// This function is given the FlowPanelCanvas's chartProps. +function viewportNeedsUpdate( + prevProps: { flowTiming: FlowTiming }, + newProps: { flowTiming: FlowTiming } +) { + return prevProps.flowTiming !== newProps.flowTiming; +} + +export const FlowPanel = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state) => { + const flowTiming = getFlowTiming(state); + return { + fullMarkerListPerThread: getFullMarkerListPerThread(state), + markerLabelGetterPerThread: getMarkerChartLabelGetterPerThread(state), + flowTiming, + timeRange: getCommittedRange(state), + previewSelection: getPreviewSelection(state), + }; + }, + mapDispatchToProps: { + updatePreviewSelection, + changeMouseTimePosition, + changeActiveFlows, + }, + component: FlowPanelImpl, +}); diff --git a/src/components/marker-chart-tab/index.css b/src/components/marker-chart-tab/index.css new file mode 100644 index 0000000000..062ddd03cb --- /dev/null +++ b/src/components/marker-chart-tab/index.css @@ -0,0 +1,9 @@ +.markerChartTabContainer { + position: relative; + min-height: 0; + flex: 1; +} + +.markerChartTabSplitter > .layout-pane { + display: flex; +} diff --git a/src/components/marker-chart-tab/index.tsx b/src/components/marker-chart-tab/index.tsx new file mode 100644 index 0000000000..ceb96da9eb --- /dev/null +++ b/src/components/marker-chart-tab/index.tsx @@ -0,0 +1,33 @@ +/* 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 SplitterLayout from 'react-splitter-layout'; +import { MarkerChart } from '../marker-chart'; +import { FlowPanel } from '../flow-panel'; + +import './index.css'; + +export function MarkerChartTab() { + return ( +
+ + + + +
+ ); +} diff --git a/src/components/marker-chart/Canvas.tsx b/src/components/marker-chart/Canvas.tsx index ec6544ad32..6049301ce3 100644 --- a/src/components/marker-chart/Canvas.tsx +++ b/src/components/marker-chart/Canvas.tsx @@ -18,6 +18,7 @@ import type { changeMouseTimePosition, changeSelectedMarker, updateBottomBoxContentsAndMaybeOpen, + activateFlowsForMarker, } from 'firefox-profiler/actions/profile-view'; type UpdatePreviewSelection = typeof updatePreviewSelection; @@ -81,6 +82,9 @@ type OwnProps = { readonly changeMouseTimePosition: ChangeMouseTimePosition; readonly changeSelectedMarker: ChangeSelectedMarker; readonly changeRightClickedMarker: ChangeRightClickedMarker; + readonly activateFlowsForMarker: WrapFunctionInDispatch< + typeof activateFlowsForMarker + >; readonly marginLeft: CssPixels; readonly marginRight: CssPixels; readonly selectedMarkerIndex: MarkerIndex | null; @@ -309,7 +313,8 @@ class MarkerChartCanvasImpl extends React.PureComponent { isHighlighted: boolean = false ) { if (isInstantMarker) { - this.drawOneInstantMarker(ctx, x, y, h, markerIndex, isHighlighted); + // this.drawOneInstantMarker(ctx, x, y, h, markerIndex, isHighlighted); + this.drawOneIntervalMarker(ctx, x, y, 1, h, markerIndex, isHighlighted); } else { this.drawOneIntervalMarker(ctx, x, y, w, h, markerIndex, isHighlighted); } @@ -385,35 +390,6 @@ class MarkerChartCanvasImpl extends React.PureComponent { } } - // x indicates the center of this marker - // y indicates the top of the row - // h indicates the available height in the row - drawOneInstantMarker( - ctx: CanvasRenderingContext2D, - x: CssPixels, - y: CssPixels, - h: CssPixels, - markerIndex: MarkerIndex, - isHighlighted: boolean - ) { - const { fillColor, strokeColor } = this._getMarkerColors( - markerIndex, - isHighlighted - ); - ctx.fillStyle = fillColor; - ctx.strokeStyle = strokeColor; - - // We're drawing a diamond shape, whose height is h - 2, and width is h / 2. - ctx.beginPath(); - ctx.moveTo(x - h / 4, y + h / 2); - ctx.lineTo(x, y + 1.5); - ctx.lineTo(x + h / 4, y + h / 2); - ctx.lineTo(x, y + h - 1.5); - ctx.closePath(); - ctx.fill(); - ctx.stroke(); - } - drawMarkers( ctx: CanvasRenderingContext2D, hoveredItem: MarkerIndex | null, @@ -868,8 +844,15 @@ class MarkerChartCanvasImpl extends React.PureComponent { }; onSelectItem = (markerIndex: MarkerIndex | null) => { - const { changeSelectedMarker, threadsKey } = this.props; + const { changeSelectedMarker, activateFlowsForMarker, threadsKey } = + this.props; changeSelectedMarker(threadsKey, markerIndex, { source: 'pointer' }); + console.log({ threadsKey, markerIndex }); + if (typeof threadsKey === 'number' && markerIndex !== null) { + console.log('hello'); + const what = activateFlowsForMarker(threadsKey, markerIndex); + console.log({ what }); + } }; onRightClickMarker = (markerIndex: MarkerIndex | null) => { diff --git a/src/components/marker-chart/index.tsx b/src/components/marker-chart/index.tsx index 93bb9a1e96..6a755c2dad 100644 --- a/src/components/marker-chart/index.tsx +++ b/src/components/marker-chart/index.tsx @@ -27,6 +27,7 @@ import { changeMouseTimePosition, changeSelectedMarker, updateBottomBoxContentsAndMaybeOpen, + activateFlowsForMarker, } from 'firefox-profiler/actions/profile-view'; import { ContextMenuTrigger } from 'firefox-profiler/components/shared/ContextMenuTrigger'; @@ -55,6 +56,7 @@ type DispatchProps = { readonly changeMouseTimePosition: typeof changeMouseTimePosition; readonly changeSelectedMarker: typeof changeSelectedMarker; readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly activateFlowsForMarker: typeof activateFlowsForMarker; }; type StateProps = { @@ -123,6 +125,7 @@ class MarkerChartImpl extends React.PureComponent { updatePreviewSelection, changeMouseTimePosition, changeRightClickedMarker, + activateFlowsForMarker, rightClickedMarkerIndex, selectedMarkerIndex, changeSelectedMarker, @@ -133,12 +136,7 @@ class MarkerChartImpl extends React.PureComponent { const maxViewportHeight = maxMarkerRows * ROW_HEIGHT; return ( -
+
{maxMarkerRows === 0 ? ( @@ -170,6 +168,7 @@ class MarkerChartImpl extends React.PureComponent { updatePreviewSelection, changeMouseTimePosition, changeRightClickedMarker, + activateFlowsForMarker, rangeStart: timeRange.start, rangeEnd: timeRange.end, rowHeight: ROW_HEIGHT, @@ -229,6 +228,7 @@ export const MarkerChart = explicitConnect<{}, StateProps, DispatchProps>({ changeRightClickedMarker, changeSelectedMarker, updateBottomBoxContentsAndMaybeOpen, + activateFlowsForMarker, }, component: MarkerChartImpl, }); diff --git a/src/components/tooltip/FlowGap.tsx b/src/components/tooltip/FlowGap.tsx new file mode 100644 index 0000000000..0c00cf7b07 --- /dev/null +++ b/src/components/tooltip/FlowGap.tsx @@ -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/. */ + +// @flow + +import * as React from 'react'; +import classNames from 'classnames'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { getThreadSelectorsFromThreadsKey } from 'firefox-profiler/selectors'; +import { formatTimestamp } from 'firefox-profiler/utils/format-numbers'; + +import { TooltipDetails, TooltipDetail } from './TooltipDetails'; + +import type { Marker, MarkerIndex } from 'firefox-profiler/types'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; +import './Marker.css'; + +type OwnProps = { + beforeGapMarkerIndex: MarkerIndex; + beforeGapMarker: Marker; + beforeGapThreadIndex: number; + afterGapMarkerIndex: MarkerIndex; + afterGapMarker: Marker; + afterGapThreadIndex: number; + className?: string; +}; + +type StateProps = { + beforeGapThreadName?: string; + afterGapThreadName?: string; + beforeGapGetMarkerLabel: (marker: MarkerIndex) => string; + afterGapGetMarkerLabel: (marker: MarkerIndex) => string; +}; + +type Props = ConnectedProps; + +/** + * This component combines Marker Schema, and custom handling to generate tooltips + * for markers. + */ +class FlowGapTooltipContents extends React.PureComponent { + _maybeRenderMarkerDuration() { + const { beforeGapMarker, afterGapMarker } = this.props; + const gapStart = beforeGapMarker.end ?? beforeGapMarker.start; + const gapEnd = afterGapMarker.start; + const duration = formatTimestamp(gapEnd - gapStart, 3, 1); + return
{duration}
; + } + + override render() { + const { className } = this.props; + const { + beforeGapMarkerIndex, + beforeGapGetMarkerLabel, + afterGapMarkerIndex, + afterGapGetMarkerLabel, + beforeGapThreadName, + afterGapThreadName, + } = this.props; + const beforeLabel = beforeGapGetMarkerLabel(beforeGapMarkerIndex); + const afterLabel = afterGapGetMarkerLabel(afterGapMarkerIndex); + + return ( +
+
+
+ {this._maybeRenderMarkerDuration()} +
Gap
+
+
+ + + {beforeLabel} + + + {beforeGapThreadName} + + + {afterLabel} + + + {afterGapThreadName} + + +
+ ); + } +} + +export const FlowGapTooltip = explicitConnect({ + mapStateToProps: (state, props) => { + const beforeGapSelectors = getThreadSelectorsFromThreadsKey( + props.beforeGapThreadIndex + ); + const afterGapSelectors = getThreadSelectorsFromThreadsKey( + props.afterGapThreadIndex + ); + return { + beforeGapThreadName: beforeGapSelectors.getFriendlyThreadName(state), + afterGapThreadName: afterGapSelectors.getFriendlyThreadName(state), + beforeGapGetMarkerLabel: + beforeGapSelectors.getMarkerTooltipLabelGetter(state), + afterGapGetMarkerLabel: + afterGapSelectors.getMarkerTooltipLabelGetter(state), + }; + }, + component: FlowGapTooltipContents, +}); diff --git a/src/profile-logic/marker-data.ts b/src/profile-logic/marker-data.ts index f8abcce05c..6fc5790f36 100644 --- a/src/profile-logic/marker-data.ts +++ b/src/profile-logic/marker-data.ts @@ -6,6 +6,7 @@ import { getFriendlyThreadName } from './profile-data'; import { removeFilePath, removeURLs, stringsToRegExp } from '../utils/string'; import { StringTable } from '../utils/string-table'; import { ensureExists, assertExhaustiveCheck } from '../utils/types'; +import { bisectionRightByKey } from '../utils/bisect'; import { INSTANT, INTERVAL, @@ -41,6 +42,18 @@ import type { MarkerSchemaByName, MarkerDisplayLocation, Tid, + Milliseconds, + ThreadIndex, + FlowMarker, + FlowSchemasByName, + ProfileFlowInfo, + FlowTiming, + IndexIntoFlowTable, + ConnectedFlowInfo, + FlowTimingRow, + FlowTimingRowType, + FlowTimingRowMarkerTable, + FlowTimingArrow, } from 'firefox-profiler/types'; /** @@ -1583,3 +1596,741 @@ export const stringsToMarkerRegExps = ( fieldMap, }; }; + +class MinHeap { + _keys: number[] = []; + _values: V[] = []; + + size(): number { + return this._values.length; + } + insert(key: number, value: V) { + this._keys.push(key); + this._values.push(value); + } + delete(handle: number) { + this._keys.splice(handle, 1); + this._values.splice(handle, 1); + } + first(): number | null { + if (this._values.length === 0) { + return null; + } + + let minI = 0; + let minKey = this._keys[0]; + for (let i = 1; i < this._keys.length; i++) { + const k = this._keys[i]; + if (k < minKey) { + minI = i; + minKey = k; + } + } + return minI; + } + get(handle: number): V { + return this._values[handle]; + } + reorder(handle: number, newKey: number) { + this._keys[handle] = newKey; + } +} + +export function computeFlowSchemasByName( + markerSchemas: MarkerSchema[] +): FlowSchemasByName { + const flowSchemasByName = new Map(); + for (const schema of markerSchemas) { + const flowFields = []; + for (const field of schema.fields) { + const key = field.key; + if (field.format === 'flow-id') { + flowFields.push({ key, isTerminating: false }); + } else if (field.format === 'terminating-flow-id') { + flowFields.push({ key, isTerminating: true }); + } + } + if (flowFields.length !== 0) { + flowSchemasByName.set(schema.name, { + flowFields, + isStackBased: schema.isStackBased === true, + }); + } + } + return flowSchemasByName; +} + +export function computeFlowMarkers( + fullMarkerList: Marker[], + stringArray: string[], + flowSchemasByName: FlowSchemasByName +): FlowMarker[] { + const flowMarkers: FlowMarker[] = []; + const currentContextFlowMarkers: number[] = []; + const currentContextEndTimes: Milliseconds[] = []; + for ( + let markerIndex = 0; + markerIndex < fullMarkerList.length; + markerIndex++ + ) { + const marker = fullMarkerList[markerIndex]; + const markerData = marker.data; + if (!markerData) { + continue; + } + const schemaName = markerData.type; + if (!schemaName) { + continue; + } + const flowSchema = flowSchemasByName.get(schemaName); + if (flowSchema === undefined) { + continue; + } + + const startTime = marker.start; + const endTime = marker.end; + + while ( + currentContextEndTimes.length !== 0 && + currentContextEndTimes[currentContextEndTimes.length - 1] < startTime + ) { + currentContextEndTimes.pop(); + currentContextFlowMarkers.pop(); + } + + const flowIDs = []; + for (const { key, isTerminating } of flowSchema.flowFields) { + const flowIDStringIndex = (markerData as any)[key]; + if (flowIDStringIndex !== undefined && flowIDStringIndex !== null) { + const flowID = stringArray[flowIDStringIndex]; + flowIDs.push({ flowID, isTerminating }); + } + } + if (flowIDs.length === 0) { + continue; + } + + const thisFlowMarkerIndex = flowMarkers.length; + const parentContextFlowMarker = + currentContextFlowMarkers.length !== 0 + ? currentContextFlowMarkers[currentContextFlowMarkers.length - 1] + : null; + flowMarkers.push({ + parentContextFlowMarker, + childContextFlowMarkers: [], + flowIDs, + startTime, + endTime, + markerIndex, + }); + if (flowSchema.isStackBased || marker.end === null) { + if (parentContextFlowMarker !== null) { + flowMarkers[parentContextFlowMarker].childContextFlowMarkers.push( + thisFlowMarkerIndex + ); + } + } + if (flowSchema.isStackBased && marker.end !== null) { + currentContextEndTimes.push(marker.end); + currentContextFlowMarkers.push(thisFlowMarkerIndex); + } + } + return flowMarkers; +} + +export function computeProfileFlowInfo( + fullMarkerListPerThread: Marker[][], + threads: RawThread[], + markerSchemas: MarkerSchema[], + shared: RawProfileSharedData +): ProfileFlowInfo { + const flowSchemasByName = computeFlowSchemasByName(markerSchemas); + const { stringArray } = shared; + + const flowMarkersPerThread: FlowMarker[][] = fullMarkerListPerThread.map( + (fullMarkerList) => { + return computeFlowMarkers(fullMarkerList, stringArray, flowSchemasByName); + } + ); + + const threadCount = flowMarkersPerThread.length; + const nextEntryHeap = new MinHeap<{ + threadIndex: number; + nextIndex: number; + }>(); + for (let threadIndex = 0; threadIndex < threadCount; threadIndex++) { + const flowMarkers = flowMarkersPerThread[threadIndex]; + if (flowMarkers.length !== 0) { + nextEntryHeap.insert(flowMarkers[0].startTime, { + threadIndex, + nextIndex: 0, + }); + } + } + + const flowMarkerFlowsPerThread: IndexIntoFlowTable[][][] = + flowMarkersPerThread.map(() => []); + + const flowTable = []; + const currentActiveFlows = new Map(); + const flowsByID = new Map(); + + while (true) { + const handle = nextEntryHeap.first(); + if (handle === null) { + break; + } + + const nextEntry = nextEntryHeap.get(handle); + const { threadIndex, nextIndex } = nextEntry; + const flowMarkerIndex = nextIndex; + const flowMarkers = flowMarkersPerThread[threadIndex]; + const flowMarker = flowMarkers[nextIndex]; + + const { markerIndex, flowIDs } = flowMarker; + const { start, end } = fullMarkerListPerThread[threadIndex][markerIndex]; + const flowMarkerHandle = { threadIndex, flowMarkerIndex }; + const flowsForThisFlowMarker = []; + for (const { flowID, isTerminating } of flowIDs) { + let flowIndex = currentActiveFlows.get(flowID); + if (flowIndex === undefined) { + flowIndex = flowTable.length; + flowTable.push({ + id: flowID, + startTime: start, + endTime: end ?? start, + flowMarkers: [flowMarkerHandle], + }); + if (!isTerminating) { + currentActiveFlows.set(flowID, flowIndex); + } + const flowsByIDEntry = flowsByID.get(flowID); + if (flowsByIDEntry === undefined) { + flowsByID.set(flowID, [flowIndex]); + } else { + flowsByIDEntry.push(flowIndex); + } + } else { + const flow = flowTable[flowIndex]; + flow.flowMarkers.push(flowMarkerHandle); + flow.endTime = end ?? start; + if (isTerminating) { + currentActiveFlows.delete(flowID); + } + } + flowsForThisFlowMarker.push(flowIndex); + } + sortAndDedup(flowsForThisFlowMarker); + flowMarkerFlowsPerThread[threadIndex][flowMarkerIndex] = + flowsForThisFlowMarker; + + const newNextIndex = nextIndex + 1; + if (newNextIndex < flowMarkers.length) { + nextEntry.nextIndex = newNextIndex; + nextEntryHeap.reorder(handle, flowMarkers[newNextIndex].startTime); + } else { + nextEntryHeap.delete(handle); + } + } + + return { + flowTable, + flowsByID, + flowMarkersPerThread, + flowMarkerFlowsPerThread, + flowSchemasByName, + }; +} + +export function getConnectedFlowInfo( + flowIndex: IndexIntoFlowTable, + profileFlowInfo: ProfileFlowInfo +): ConnectedFlowInfo { + const { flowTable, flowMarkersPerThread, flowMarkerFlowsPerThread } = + profileFlowInfo; + const directlyConnectedFlows: IndexIntoFlowTable[] = []; + const incomingContextFlows: IndexIntoFlowTable[] = []; + const outgoingContextFlows: IndexIntoFlowTable[] = []; + + const flow = flowTable[flowIndex]; + for (const { threadIndex, flowMarkerIndex } of flow.flowMarkers) { + const thisMarkerFlows = + flowMarkerFlowsPerThread[threadIndex][flowMarkerIndex]; + for (const directlyConnectedFlowIndex of thisMarkerFlows) { + if (directlyConnectedFlowIndex !== flowIndex) { + directlyConnectedFlows.push(directlyConnectedFlowIndex); + } + } + + const flowMarker: FlowMarker = + flowMarkersPerThread[threadIndex][flowMarkerIndex]; + + const incomingFlowMarkerIndex = flowMarker.parentContextFlowMarker; + if (incomingFlowMarkerIndex !== null) { + const incomingMarkerFlows = + flowMarkerFlowsPerThread[threadIndex][incomingFlowMarkerIndex]; + for (const incomingContextFlowIndex of incomingMarkerFlows) { + if (incomingContextFlowIndex !== flowIndex) { + incomingContextFlows.push(incomingContextFlowIndex); + } + } + } + + for (const outgoingFlowMarkerIndex of flowMarker.childContextFlowMarkers) { + const outgoingMarkerFlows = + flowMarkerFlowsPerThread[threadIndex][outgoingFlowMarkerIndex]; + for (const outgoingContextFlowIndex of outgoingMarkerFlows) { + if (outgoingContextFlowIndex !== flowIndex) { + outgoingContextFlows.push(outgoingContextFlowIndex); + } + } + } + } + sortAndDedup(directlyConnectedFlows); + sortAndDedup(incomingContextFlows); + sortAndDedup(outgoingContextFlows); + return { + directlyConnectedFlows, + incomingContextFlows, + outgoingContextFlows, + }; +} + +export function lookupFlow( + flowID: string, + time: Milliseconds, + profileFlowInfo: ProfileFlowInfo +): IndexIntoFlowTable | null { + const { flowsByID, flowTable } = profileFlowInfo; + const candidateFlows = flowsByID.get(flowID); + if (candidateFlows === undefined) { + return null; + } + const index = + bisectionRightByKey( + candidateFlows, + time, + (flowIndex) => flowTable[flowIndex].startTime + ) - 1; + if (index === -1) { + return null; + } + return candidateFlows[index]; +} + +export function computeMarkerFlows( + threadIndex: number, + markerIndex: MarkerIndex, + profileFlowInfo: ProfileFlowInfo, + fullMarkerListPerThread: Marker[][], + stringTable: StringTable +): IndexIntoFlowTable[] | null { + const marker = fullMarkerListPerThread[threadIndex][markerIndex]; + const markerData = marker.data; + if (!markerData) { + return null; + } + const markerType = markerData.type; + if (!markerType) { + return null; + } + const flowSchema = profileFlowInfo.flowSchemasByName.get(markerType); + if (flowSchema === undefined) { + return null; + } + + const flowIndexes = []; + for (const { key } of flowSchema.flowFields) { + const fieldValue = (markerData as any)[key]; + if (fieldValue === undefined || fieldValue === null) { + continue; + } + const flowID = stringTable.getString(fieldValue); + const flowIndex = lookupFlow(flowID, marker.start, profileFlowInfo); + if (flowIndex === null) { + console.error( + `Could not find flow for ID ${flowID} at time ${marker.start}!` + ); + continue; + } + flowIndexes.push(flowIndex); + } + dedupConsecutive(flowIndexes); + return flowIndexes.length !== 0 ? flowIndexes : null; +} + +function sortAndDedup(array: number[]) { + array.sort((a, b) => a - b); + dedupConsecutive(array); +} + +function dedupConsecutive(array: T[]) { + if (array.length === 0) { + return; + } + + let prev = array[0]; + for (let i = 1; i < array.length; i++) { + const curr = array[i]; + if (prev === curr) { + array.splice(i, 1); + i--; + } else { + prev = curr; + } + } +} + +export function printMarkerFlows( + markerThreadIndex: number, + markerIndex: MarkerIndex, + profileFlowInfo: ProfileFlowInfo, + threads: RawThread[], + fullMarkerListPerThread: Marker[][], + stringTable: StringTable +) { + const markerFlows = computeMarkerFlows( + markerThreadIndex, + markerIndex, + profileFlowInfo, + fullMarkerListPerThread, + stringTable + ); + if (markerFlows === null) { + console.log('This marker is not part of any flows.'); + return; + } + + const { flowTable } = profileFlowInfo; + const flowCount = markerFlows.length; + if (flowCount === 1) { + const flowIndex = markerFlows[0]; + console.log( + `This marker is part of one flow: ${flowTable[flowIndex].id} (index ${flowIndex})` + ); + } else { + console.log( + `This marker is part of ${flowCount} flows:`, + markerFlows.map((flowIndex) => flowTable[flowIndex].id) + ); + } + + for (const flowIndex of markerFlows) { + printFlow(flowIndex, profileFlowInfo, threads, fullMarkerListPerThread); + } +} + +export function printFlow( + flowIndex: IndexIntoFlowTable, + profileFlowInfo: ProfileFlowInfo, + threads: RawThread[], + fullMarkerListPerThread: Marker[][] +) { + const { flowTable, flowMarkersPerThread, flowMarkerFlowsPerThread } = + profileFlowInfo; + + const flow = flowTable[flowIndex]; + console.log(`Flow ${flow.id} (index ${flowIndex}):`, flow); + console.log(`This flow contains ${flow.flowMarkers.length} markers:`); + for (const { threadIndex, flowMarkerIndex } of flow.flowMarkers) { + const flowMarker = flowMarkersPerThread[threadIndex][flowMarkerIndex]; + const otherMarkerIndex = flowMarker.markerIndex; + const thread = threads[threadIndex]; + const marker = fullMarkerListPerThread[threadIndex][otherMarkerIndex]; + console.log( + ` - marker ${otherMarkerIndex} (thread index: ${threadIndex}) at time ${flowMarker.startTime} on thread ${thread.name} (pid: ${thread.pid}, tid: ${thread.tid}):`, + marker + ); + const directlyConnectedFlows = flowMarkerFlowsPerThread[threadIndex][ + flowMarkerIndex + ].filter((otherFlowIndex) => otherFlowIndex !== flowIndex); + const incomingContextFlows = + flowMarker.parentContextFlowMarker !== null + ? flowMarkerFlowsPerThread[threadIndex][ + flowMarker.parentContextFlowMarker + ].filter((otherFlowIndex) => otherFlowIndex !== flowIndex) + : []; + const outgoingContextFlows = []; + for (const childFlowMarkerIndex of flowMarker.childContextFlowMarkers) { + for (const outgoingFlow of flowMarkerFlowsPerThread[threadIndex][ + childFlowMarkerIndex + ]) { + if (outgoingFlow !== flowIndex) { + outgoingContextFlows.push(outgoingFlow); + } + } + } + sortAndDedup(outgoingContextFlows); + if (directlyConnectedFlows.length !== 0) { + console.log( + `Directly connected flows on this marker: ${directlyConnectedFlows.join(', ')}` + ); + } + if (incomingContextFlows.length !== 0) { + console.log( + `Incoming context flows on this marker: ${incomingContextFlows.join(', ')}` + ); + } + if (outgoingContextFlows.length !== 0) { + console.log( + `Outgoing context flows on this marker: ${outgoingContextFlows.join(', ')}` + ); + } + } + + // const connections = getConnectedFlowInfo(flowIndex, profileFlowInfo); + // if (connections.directlyConnectedFlows.length !== 0) { + // console.log( + // `Directly connected flows: ${connections.directlyConnectedFlows.join(', ')}` + // ); + // } + // if (connections.incomingContextFlows.length !== 0) { + // console.log( + // `Incoming context flows: ${connections.incomingContextFlows.join(', ')}` + // ); + // } + // if (connections.outgoingContextFlows.length !== 0) { + // console.log( + // `Outgoing context flows: ${connections.outgoingContextFlows.join(', ')}` + // ); + // } +} + +export function computeFlowTiming( + profileFlowInfo: ProfileFlowInfo, + activeFlows: IndexIntoFlowTable[] +): FlowTiming { + let incomingContextFlows = []; + let directlyConnectedFlows = []; + let outgoingContextFlows = []; + + for (const flow of activeFlows) { + const connectedFlows = getConnectedFlowInfo(flow, profileFlowInfo); + incomingContextFlows.push(...connectedFlows.incomingContextFlows); + directlyConnectedFlows.push(...connectedFlows.directlyConnectedFlows); + outgoingContextFlows.push(...connectedFlows.outgoingContextFlows); + } + sortAndDedup(incomingContextFlows); + sortAndDedup(directlyConnectedFlows); + sortAndDedup(outgoingContextFlows); + + directlyConnectedFlows = directlyConnectedFlows.filter( + (icf) => activeFlows.indexOf(icf) === -1 + ); + incomingContextFlows = incomingContextFlows.filter( + (icf) => + activeFlows.indexOf(icf) === -1 && + directlyConnectedFlows.indexOf(icf) === -1 + ); + outgoingContextFlows = outgoingContextFlows.filter( + (icf) => + activeFlows.indexOf(icf) === -1 && + directlyConnectedFlows.indexOf(icf) === -1 && + incomingContextFlows.indexOf(icf) === -1 + ); + + const rawRows: Array<[FlowTimingRowType, IndexIntoFlowTable]> = [ + ...incomingContextFlows.map<[FlowTimingRowType, IndexIntoFlowTable]>( + (flowIndex) => ['INCOMING_CONTEXT', flowIndex] + ), + ...activeFlows.map<[FlowTimingRowType, IndexIntoFlowTable]>((flowIndex) => [ + 'ACTIVE', + flowIndex, + ]), + ...directlyConnectedFlows.map<[FlowTimingRowType, IndexIntoFlowTable]>( + (flowIndex) => ['DIRECTLY_CONNECTED', flowIndex] + ), + ...outgoingContextFlows.map<[FlowTimingRowType, IndexIntoFlowTable]>( + (flowIndex) => ['OUTGOING_CONTEXT', flowIndex] + ), + ]; + + const rows: FlowTimingRow[] = []; + + const { flowTable, flowMarkersPerThread } = profileFlowInfo; + + const flowIndexToRowIndex = new Map(); + + for (let rowIndex = 0; rowIndex < rawRows.length; rowIndex++) { + const [rowType, flowIndex] = rawRows[rowIndex]; + flowIndexToRowIndex.set(flowIndex, rowIndex); + + const flow = flowTable[flowIndex]; + const { flowMarkers, startTime, endTime } = flow; + const flowMarkerCount = flowMarkers.length; + + const markers: FlowTimingRowMarkerTable = { + length: flowMarkerCount, + threadIndex: new Int32Array(flowMarkerCount), + markerIndex: new Int32Array(flowMarkerCount), + flowMarkerIndex: new Int32Array(flowMarkerCount), + startTime: new Float64Array(flowMarkerCount), + endTime: new Float64Array(flowMarkerCount), + isInstant: new Uint8Array(flowMarkerCount), + }; + + for (let i = 0; i < flowMarkerCount; i++) { + const { threadIndex, flowMarkerIndex } = flowMarkers[i]; + const flowMarker = flowMarkersPerThread[threadIndex][flowMarkerIndex]; + const { markerIndex, startTime, endTime } = flowMarker; + markers.threadIndex[i] = threadIndex; + markers.markerIndex[i] = markerIndex; + markers.flowMarkerIndex[i] = flowMarkerIndex; + markers.startTime[i] = startTime; + if (endTime === null) { + markers.endTime[i] = startTime; + markers.isInstant[i] = 1; + } else { + markers.endTime[i] = endTime; + } + } + + rows.push({ + label: `Flow ${flowIndex}`, + rowType, + flowIndex, + flowStart: startTime, + flowEnd: ensureExists(endTime), + markers, + }); + } + + return { rows, flowIndexToRowIndex, profileFlowInfo }; +} + +function _arrowForMarker( + flowMarkerIndex: number, + flowIndexToRowIndex: Map, + flowMarkers: FlowMarker[], + flowMarkerFlows: IndexIntoFlowTable[][] +): FlowTimingArrow | null { + const { startTime, parentContextFlowMarker } = flowMarkers[flowMarkerIndex]; + + const rowIndexesFrom = []; + const rowIndexesTo = []; + let minRowIndex = -1; + let maxRowIndex = -1; + if (parentContextFlowMarker !== null) { + for (const flowIndex of flowMarkerFlows[parentContextFlowMarker]) { + const rowIndex = flowIndexToRowIndex.get(flowIndex); + if (rowIndex !== undefined) { + rowIndexesFrom.push(rowIndex); + if (minRowIndex === -1) { + minRowIndex = rowIndex; + maxRowIndex = rowIndex; + } else { + if (rowIndex < minRowIndex) { + minRowIndex = rowIndex; + } + if (rowIndex > maxRowIndex) { + maxRowIndex = rowIndex; + } + } + } + } + } + for (const flowIndex of flowMarkerFlows[flowMarkerIndex]) { + const rowIndex = flowIndexToRowIndex.get(flowIndex); + if (rowIndex !== undefined) { + rowIndexesTo.push(rowIndex); + if (minRowIndex === -1) { + minRowIndex = rowIndex; + maxRowIndex = rowIndex; + } else { + if (rowIndex < minRowIndex) { + minRowIndex = rowIndex; + } + if (rowIndex > maxRowIndex) { + maxRowIndex = rowIndex; + } + } + } + } + if (minRowIndex === maxRowIndex) { + return null; + } + return { + time: startTime, + rowIndexesFrom, + rowIndexesTo, + minRowIndex, + maxRowIndex, + }; +} + +export function computeArrowsRelatedToMarker( + threadIndex: ThreadIndex, + flowMarkerIndex: number, + flowTiming: FlowTiming + // flowIndexToRowIndex: Map, + // profileFlowInfo: ProfileFlowInfo +): FlowTimingArrow[] { + // We have marker-shared-between-flows "arrows" (or actually connections) + // And we have marker-has-this-parent arrows. + + // For shared-between-flows marker, as we iterate over a flow's flow markers, we find (threadIndex, flowMarkerIndex) pairs. + // From the flow marekr, we can get its "flow marker flows", which has the flow indexes for all the flows it is part of. + // In this function, we make it so that we add the arrows for the shared flows only when we encounter the marker in the first flow of the set. + // We also only do it for the activeFlows and not for the context flows. For the context flows we might not have all the directly-connected flows present in the rows, specifically we may not have the flow with the minimum index. + + const { flowIndexToRowIndex, profileFlowInfo } = flowTiming; + const { flowMarkersPerThread, flowMarkerFlowsPerThread } = profileFlowInfo; + const flowMarkers = flowMarkersPerThread[threadIndex]; + const flowMarkerFlows = flowMarkerFlowsPerThread[threadIndex]; + + const arrows: FlowTimingArrow[] = []; + + const flowMarker = flowMarkersPerThread[threadIndex][flowMarkerIndex]; + let incomingFlowMarkerIndex = flowMarkerIndex; + let incomingFlowMarker = flowMarker; + while (true) { + const { parentContextFlowMarker } = incomingFlowMarker; + const arrow = _arrowForMarker( + incomingFlowMarkerIndex, + flowIndexToRowIndex, + flowMarkers, + flowMarkerFlows + ); + if (arrow === null) { + break; + } + arrows.push(arrow); + if (parentContextFlowMarker === null) { + break; + } + incomingFlowMarkerIndex = parentContextFlowMarker; + incomingFlowMarker = flowMarkers[incomingFlowMarkerIndex]; + } + + const stack = [ + { + childMarkers: flowMarker.childContextFlowMarkers, + currentIndex: 0, + }, + ]; + while (stack.length > 0) { + const stackTop = stack[stack.length - 1]; + const { currentIndex, childMarkers } = stackTop; + if (currentIndex >= childMarkers.length) { + stack.pop(); + continue; + } + const currentFlowMarkerIndex = childMarkers[currentIndex]; + stackTop.currentIndex = currentIndex + 1; + const arrow = _arrowForMarker( + currentFlowMarkerIndex, + flowIndexToRowIndex, + flowMarkers, + flowMarkerFlows + ); + if (arrow !== null) { + arrows.push(arrow); + const children = + flowMarkers[currentFlowMarkerIndex].childContextFlowMarkers; + if (children.length !== 0) { + stack.push({ childMarkers: children, currentIndex: 0 }); + } + } + } + return arrows; +} diff --git a/src/profile-logic/marker-timing.ts b/src/profile-logic/marker-timing.ts index b8ee8dd440..49d8b983cf 100644 --- a/src/profile-logic/marker-timing.ts +++ b/src/profile-logic/marker-timing.ts @@ -8,12 +8,31 @@ import type { MarkerIndex, MarkerTiming, MarkerTimingAndBuckets, + MarkerSchemaByName, } from 'firefox-profiler/types'; +import { getSchemaFromMarker } from './marker-schema'; + // Arbitrarily set an upper limit for adding marker depths, avoiding very long // overlapping marker timings. const MAX_STACKING_DEPTH = 300; +function _createEmptyTiming( + name: string, + bucket: string, + instantOnly: boolean +): MarkerTiming { + return { + start: [], + end: [], + index: [], + name, + bucket, + instantOnly, + length: 0, + }; +} + /** * This function computes the timing information for laying out the markers in the * MarkerChart component. Each marker is put into a single row based on its name. In @@ -86,13 +105,15 @@ export function getMarkerTiming( markerIndexes: MarkerIndex[], // Categories can be null for things like Network Markers, where we don't care to // break things up by category. - categories: CategoryList | null + categories?: CategoryList, + markerSchemaByName?: MarkerSchemaByName ): MarkerTiming[] { - // Each marker type will have it's own timing information, later collapse these into + // Each marker type will have its own timing information, later collapse these into // a single array. const intervalMarkerTimingsMap: Map = new Map(); // Instant markers are on separate lines. const instantMarkerTimingsMap: Map = new Map(); + const stackBasedMarkerTimings: MarkerTiming[] = []; // Go through all of the markers. for (const markerIndex of markerIndexes) { @@ -109,35 +130,41 @@ export function getMarkerTiming( markerTiming.length++; }; - const bucketName = categories ? categories[marker.category].name : 'None'; + const schema = markerSchemaByName + ? getSchemaFromMarker(markerSchemaByName, marker.data) + : null; + let isStackBased = schema !== null && schema.isStackBased === true; + if (marker.end === null) { + // XXXmstange let's see how we like this + isStackBased = true; + } + + // eslint-disable-next-line no-nested-ternary + const bucketName = isStackBased + ? 'Stack' + : categories + ? categories[marker.category].name + : 'None'; // We want to group all network requests in the same line. Indeed they all // have different names and they'd end up with one single request in each // line without this special handling. - const markerLineName = - marker.data && marker.data.type === 'Network' + // eslint-disable-next-line no-nested-ternary + const markerLineName = isStackBased + ? 'Stack' + : marker.data && marker.data.type === 'Network' ? 'Network Requests' : marker.name; - const emptyTiming = ({ - instantOnly, - }: { - instantOnly: boolean; - }): MarkerTiming => ({ - start: [], - end: [], - index: [], - name: markerLineName, - bucket: bucketName, - instantOnly, - length: 0, - }); - - if (marker.end === null) { + if (marker.end === null && !isStackBased) { // This is an instant marker. let instantMarkerTiming = instantMarkerTimingsMap.get(markerLineName); if (!instantMarkerTiming) { - instantMarkerTiming = emptyTiming({ instantOnly: true }); + instantMarkerTiming = _createEmptyTiming( + markerLineName, + bucketName, + true + ); instantMarkerTimingsMap.set(markerLineName, instantMarkerTiming); } addCurrentMarkerToMarkerTiming(instantMarkerTiming); @@ -145,16 +172,18 @@ export function getMarkerTiming( } // This is an interval marker. - let markerTimingsForName = intervalMarkerTimingsMap.get(markerLineName); - if (markerTimingsForName === undefined) { - markerTimingsForName = []; - intervalMarkerTimingsMap.set(markerLineName, markerTimingsForName); + let timingRows = isStackBased + ? stackBasedMarkerTimings + : intervalMarkerTimingsMap.get(markerLineName); + if (timingRows === undefined) { + timingRows = []; + intervalMarkerTimingsMap.set(markerLineName, timingRows); } // Find the first row where the new marker fits. // Since the markers are sorted, look at the last added marker in this row. If // the new marker fits, go ahead and insert it. - const foundMarkerTiming = markerTimingsForName.find( + const foundMarkerTiming = timingRows.find( (markerTiming) => markerTiming.end[markerTiming.length - 1] <= marker.start ); @@ -163,30 +192,34 @@ export function getMarkerTiming( addCurrentMarkerToMarkerTiming(foundMarkerTiming); continue; } - - if (markerTimingsForName.length >= MAX_STACKING_DEPTH) { + if (timingRows.length >= MAX_STACKING_DEPTH) { // There are too many markers stacked around the same time already, let's // ignore this marker. continue; } // Otherwise, let's add a new row! - const newTiming = emptyTiming({ instantOnly: false }); + const newTiming = _createEmptyTiming(markerLineName, bucketName, false); addCurrentMarkerToMarkerTiming(newTiming); - markerTimingsForName.push(newTiming); + timingRows.push(newTiming); continue; } - // Flatten out the maps into a single array. // One item in this array is one line in the drawn canvas. - const allMarkerTimings = [...instantMarkerTimingsMap.values()].concat( - ...intervalMarkerTimingsMap.values() - ); + const allMarkerTimings = [...instantMarkerTimingsMap.values()] + .concat(...intervalMarkerTimingsMap.values()) + .concat(...stackBasedMarkerTimings); // Sort all the marker timings in place, first by the bucket, then by their names. allMarkerTimings.sort((a, b) => { if (a.bucket !== b.bucket) { // Sort by buckets first. + if (a.bucket === 'Stack') { + return -1; + } + if (b.bucket === 'Stack') { + return 1; + } // Show the 'Test' category first. Test markers are almost guaranteed to // be the most relevant when they exist. if (a.bucket === 'Test') { @@ -286,16 +319,18 @@ export function getMarkerTiming( * ] */ export function getMarkerTimingAndBuckets( - getMarker: (param: MarkerIndex) => Marker, + getMarker: (marker: MarkerIndex) => Marker, markerIndexes: MarkerIndex[], // Categories can be null for things like Network Markers, where we don't care to // break things up by category. - categories: CategoryList | null + categories?: CategoryList, + markerSchemaByName?: MarkerSchemaByName ): MarkerTimingAndBuckets { const allMarkerTimings = getMarkerTiming( getMarker, markerIndexes, - categories + categories, + markerSchemaByName ); // Interleave the bucket names in between the marker timings. diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index bebe69468d..7b878e916c 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -23,6 +23,7 @@ import type { AssemblyViewState, IsOpenPerPanelState, TabID, + IndexIntoFlowTable, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -648,6 +649,17 @@ const isBottomBoxOpenPerPanel: Reducer = ( } }; +const activeFlows: Reducer = (state = [], action) => { + switch (action.type) { + case 'CHANGE_ACTIVE_FLOWS': { + const { activeFlows } = action; + return activeFlows; + } + default: + return state; + } +}; + /** * This value is only set from the URL and never changed. */ @@ -672,6 +684,7 @@ const profileSpecific = combineReducers({ transforms, sourceView, assemblyView, + activeFlows, isBottomBoxOpenPerPanel, timelineType, globalTrackOrder, diff --git a/src/selectors/flow.ts b/src/selectors/flow.ts new file mode 100644 index 0000000000..8ce86ed213 --- /dev/null +++ b/src/selectors/flow.ts @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// @flow +import { createSelector } from 'reselect'; + +import { + computeProfileFlowInfo, + computeFlowTiming, +} from '../profile-logic/marker-data'; +import { getThreadSelectors } from './per-thread'; +import { getActiveFlows } from './url-state'; +import type { ThreadSelectors } from './per-thread'; +import { + getThreads, + getMarkerSchema, + getRawProfileSharedData, +} from './profile'; + +import type { + Selector, + State, + MarkerIndex, + Marker, + ProfileFlowInfo, + FlowTiming, +} from 'firefox-profiler/types'; + +function _arraysShallowEqual(arr1: any[], arr2: any[]): boolean { + return arr1.length === arr2.length && arr1.every((val, i) => val === arr2[i]); +} + +function _createSelectorForAllThreads( + f: (selectors: ThreadSelectors, state: State) => T +): Selector { + let previousOutputPerThread: T[] = []; + return function recomputeSelectorForAllThreads(state: State): T[] { + const threads = getThreads(state); + let outputPerThread = threads.map((_thread, i) => { + const threadSelectors = getThreadSelectors(i); + return f(threadSelectors, state); + }); + if (_arraysShallowEqual(outputPerThread, previousOutputPerThread)) { + outputPerThread = previousOutputPerThread; + } + previousOutputPerThread = outputPerThread; + return outputPerThread; + }; +} + +export const getFullMarkerListPerThread: Selector = + _createSelectorForAllThreads(({ getFullMarkerList }, state) => + getFullMarkerList(state) + ); + +export const getMarkerChartLabelGetterPerThread: Selector< + Array<(marker: MarkerIndex) => string> +> = _createSelectorForAllThreads(({ getMarkerChartLabelGetter }, state) => + getMarkerChartLabelGetter(state) +); + +export const getProfileFlowInfo: Selector = createSelector( + getFullMarkerListPerThread, + getThreads, + getMarkerSchema, + getRawProfileSharedData, + computeProfileFlowInfo +); + +export const getFlowTiming: Selector = createSelector( + getProfileFlowInfo, + getActiveFlows, + computeFlowTiming +); diff --git a/src/selectors/index.ts b/src/selectors/index.ts index 69b552d23e..bf8ef154b6 100644 --- a/src/selectors/index.ts +++ b/src/selectors/index.ts @@ -10,6 +10,7 @@ export * from './publish'; export * from './zipped-profiles'; export * from './cpu'; export * from './code'; +export * from './flow'; import * as app from './app'; import { @@ -23,6 +24,7 @@ import * as publish from './publish'; import * as zippedProfiles from './zipped-profiles'; import * as cpu from './cpu'; import * as code from './code'; +import * as flow from './flow'; const _selectorsForConsole = { app, @@ -35,6 +37,7 @@ const _selectorsForConsole = { selectedNode, cpu, code, + flow, }; // Exports require explicit typing. Deduce the type with typeof. diff --git a/src/selectors/per-thread/markers.ts b/src/selectors/per-thread/markers.ts index 333427b3e7..10da2b8291 100644 --- a/src/selectors/per-thread/markers.ts +++ b/src/selectors/per-thread/markers.ts @@ -471,6 +471,7 @@ export function getMarkerSelectorsPerThread( getMarkerGetter, getMarkerChartMarkerIndexes, ProfileSelectors.getCategories, + ProfileSelectors.getMarkerSchemaByName, MarkerTimingLogic.getMarkerTimingAndBuckets ); @@ -523,7 +524,6 @@ export function getMarkerSelectorsPerThread( const getNetworkTrackTiming: Selector = createSelector( getMarkerGetter, getNetworkMarkerIndexes, - () => null, MarkerTimingLogic.getMarkerTiming ); @@ -534,7 +534,6 @@ export function getMarkerSelectorsPerThread( const getUserTimingMarkerTiming: Selector = createSelector( getMarkerGetter, getUserTimingMarkerIndexes, - () => null, MarkerTimingLogic.getMarkerTiming ); diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index 48b7e17146..560c1c760d 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -31,6 +31,7 @@ import type { NativeSymbolInfo, TabID, IndexIntoSourceTable, + IndexIntoFlowTable, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -98,6 +99,8 @@ export const getNetworkSearchString: Selector = (state) => getProfileSpecificState(state).networkSearchString; export const getSelectedTab: Selector = (state) => getUrlState(state).selectedTab; +export const getActiveFlows: Selector = (state) => + getProfileSpecificState(state).activeFlows; export const getInvertCallstack: Selector = (state) => getSelectedTab(state) === 'calltree' && getProfileSpecificState(state).invertCallstack; @@ -331,6 +334,7 @@ function _shouldAllowSymbolServerUrl(symbolServerUrl: string) { const otherAllowedHostnames = [ 'symbols.mozilla.org', 'symbolication.services.mozilla.com', + 'mozilla.symbols.samplyprofiler.com', ]; if (!otherAllowedHostnames.includes(url.hostname)) { console.error( diff --git a/src/test/__snapshots__/url-handling.test.ts.snap b/src/test/__snapshots__/url-handling.test.ts.snap index bd9e77b27a..93fb601058 100644 --- a/src/test/__snapshots__/url-handling.test.ts.snap +++ b/src/test/__snapshots__/url-handling.test.ts.snap @@ -7,7 +7,7 @@ exports[`symbolServerUrl will allow an allowed https host 1`] = `Array []`; exports[`symbolServerUrl will error when switching to an allowed but non-https host 1`] = ` Array [ Array [ - "HTTPS is required for non-localhost symbol servers. Rejecting http://symbolication.services.mozilla.com/ and defaulting to https://symbolication.services.mozilla.com.", + "HTTPS is required for non-localhost symbol servers. Rejecting http://symbolication.services.mozilla.com/ and defaulting to https://mozilla.symbols.samplyprofiler.com.", ], ] `; @@ -15,7 +15,7 @@ Array [ exports[`symbolServerUrl will error when switching to an invalid host 1`] = ` Array [ Array [ - "The symbol server URL was not valid. Rejecting invalid and defaulting to https://symbolication.services.mozilla.com.", + "The symbol server URL was not valid. Rejecting invalid and defaulting to https://mozilla.symbols.samplyprofiler.com.", [TypeError: Invalid URL: invalid], ], ] @@ -24,7 +24,7 @@ Array [ exports[`symbolServerUrl will error when switching to an unknown host 1`] = ` Array [ Array [ - "The symbol server URL was not in the list of allowed domains. Rejecting https://symbolication.services.mozilla.com.example.com/symbols and defaulting to https://symbolication.services.mozilla.com.", + "The symbol server URL was not in the list of allowed domains. Rejecting https://symbolication.services.mozilla.com.example.com/symbols and defaulting to https://mozilla.symbols.samplyprofiler.com.", ], ] `; diff --git a/src/test/store/receive-profile.test.ts b/src/test/store/receive-profile.test.ts index b00a183beb..01216c0650 100644 --- a/src/test/store/receive-profile.test.ts +++ b/src/test/store/receive-profile.test.ts @@ -77,6 +77,7 @@ import { simulateOldWebChannelAndFrameScript, simulateWebChannel, } from '../fixtures/mocks/web-channel'; +import { SYMBOL_SERVER_URL } from 'firefox-profiler/app-logic/constants'; function simulateSymbolStoreHasNoCache() { // SymbolStoreDB is a mock, but Flow doesn't know this. That's why we use @@ -815,7 +816,7 @@ describe('actions/receive-profile', function () { expect( window.fetchMock.callHistory.lastCall( - 'https://symbolication.services.mozilla.com/symbolicate/v5' + `${SYMBOL_SERVER_URL}/symbolicate/v5` )?.options ).toEqual( expect.objectContaining({ @@ -833,7 +834,7 @@ describe('actions/receive-profile', function () { expect( window.fetchMock.callHistory.lastCall( - 'https://symbolication.services.mozilla.com/symbolicate/v5' + `${SYMBOL_SERVER_URL}/symbolicate/v5` )?.options ).toEqual( expect.objectContaining({ @@ -913,7 +914,7 @@ describe('actions/receive-profile', function () { 'https://storage.googleapis.com/profile-store/FAKEHASH', unsymbolicatedProfile ) - .post('https://symbolication.services.mozilla.com/symbolicate/v5', {}); + .post(`${SYMBOL_SERVER_URL}/symbolicate/v5`, {}); simulateSymbolStoreHasNoCache(); @@ -925,7 +926,7 @@ describe('actions/receive-profile', function () { expect( window.fetchMock.callHistory.lastCall( - 'https://symbolication.services.mozilla.com/symbolicate/v5' + `${SYMBOL_SERVER_URL}/symbolicate/v5` )?.options ).toEqual( expect.objectContaining({ @@ -1425,7 +1426,7 @@ describe('actions/receive-profile', function () { expect( window.fetchMock.callHistory.lastCall( - 'https://symbolication.services.mozilla.com/symbolicate/v5' + `${SYMBOL_SERVER_URL}/symbolicate/v5` )?.options ).toEqual( expect.objectContaining({ diff --git a/src/types/actions.ts b/src/types/actions.ts index 940b018500..b7bf70074a 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -23,6 +23,7 @@ import type { MarkerIndex, ThreadsKey, NativeSymbolInfo, + IndexIntoFlowTable, } from './profile-derived'; import type { FuncToFuncsMap } from '../profile-logic/symbolication'; import type { TemporaryError } from '../utils/errors'; @@ -223,6 +224,10 @@ type ProfileAction = readonly threadsKey: ThreadsKey; readonly markerIndex: MarkerIndex | null; } + | { + readonly type: 'CHANGE_ACTIVE_FLOWS'; + readonly activeFlows: IndexIntoFlowTable[]; + } | { readonly type: 'UPDATE_PREVIEW_SELECTION'; readonly previewSelection: PreviewSelection | null; diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index 3ee00c355b..ad84882e1d 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -521,6 +521,120 @@ export type ThreadWithReservedFunctions = { >; }; +export type GlobalFlowMarkerHandle = { + threadIndex: number; + flowMarkerIndex: number; +}; + +// An index into the global flow table. +export type IndexIntoFlowTable = number; + +export type Flow = { + id: string; + startTime: Milliseconds; + endTime: Milliseconds | null; + // All markers which mention this flow, ordered by start time. + flowMarkers: GlobalFlowMarkerHandle[]; +}; + +export type ConnectedFlowInfo = { + // Flows whose marker set has a non-empty intersection with our marker set. + directlyConnectedFlows: IndexIntoFlowTable[]; + // Flows which have at least one marker in their marker set was a stack-based + // marker which was already running higher up on the stack when at least one + // of our stack-based or instant markers was running on the same thread. + // All flows in our incomingContextFlows set have this flow in their + // outgoingContextFlows set. + incomingContextFlows: IndexIntoFlowTable[]; + // Flows which have at least one stack-based or instant marker in their marker + // set which was running when one of the stack-based markers in our set was + // running higher up on the same thread's stack. + // All flows in our outgoingContextFlows set have this flow in their + // incomingContextFlows set. + outgoingContextFlows: IndexIntoFlowTable[]; +}; + +type FlowIDAndTerminating = { + flowID: string; + isTerminating: boolean; +}; + +export type FlowMarker = { + markerIndex: number; + startTime: Milliseconds; + endTime: Milliseconds | null; + // The index of the closest stack-based interval flow marker that encompasses + // this marker. ("Closest" means "with the most recent start time".) + // If non-null, parentContextFlowMarker is lower the index of this flow marker, + // i.e. this can only point "backwards" within the thread's flow markers array. + parentContextFlowMarker: number | null; + // The indexes of flow markers which have this flow marker as their parentContextFlowMarker. + // All indexes in this array after the index of this flow marker. + childContextFlowMarkers: number[]; + flowIDs: FlowIDAndTerminating[]; +}; + +export type FlowFieldDescriptor = { + key: string; + isTerminating: boolean; +}; + +export type FlowSchema = { + flowFields: FlowFieldDescriptor[]; + isStackBased: boolean; +}; + +export type FlowSchemasByName = Map; + +export type ProfileFlowInfo = { + flowTable: Flow[]; + flowsByID: Map; + flowMarkersPerThread: FlowMarker[][]; + // For each (threadIndex, flowMarkerIndex), the indexes of the flows (in + // ascending order) this marker is part of. + flowMarkerFlowsPerThread: IndexIntoFlowTable[][][]; + flowSchemasByName: FlowSchemasByName; +}; + +export type FlowTiming = { + rows: FlowTimingRow[]; + flowIndexToRowIndex: Map; + profileFlowInfo: ProfileFlowInfo; +}; + +export type FlowTimingRowType = + | 'INCOMING_CONTEXT' + | 'ACTIVE' + | 'DIRECTLY_CONNECTED' + | 'OUTGOING_CONTEXT'; + +export type FlowTimingRow = { + rowType: FlowTimingRowType; + label: string; + flowIndex: IndexIntoFlowTable; + flowStart: Milliseconds; + flowEnd: Milliseconds; + markers: FlowTimingRowMarkerTable; +}; + +export type FlowTimingRowMarkerTable = { + length: number; + threadIndex: Int32Array; // ThreadIndex[], + markerIndex: Int32Array; // MarkerIndex[], + flowMarkerIndex: Int32Array; // FlowMarkerIndex[], + startTime: Float64Array; // Milliseconds[], + endTime: Float64Array; // Milliseconds[], + isInstant: Uint8Array; // boolean[], +}; + +export type FlowTimingArrow = { + time: Milliseconds; + rowIndexesFrom: number[]; + rowIndexesTo: number[]; + minRowIndex: number; + maxRowIndex: number; +}; + /** * The marker timing contains the necessary information to draw markers very quickly * in the marker chart. It represents a single row of markers in the chart. diff --git a/src/types/state.ts b/src/types/state.ts index 54c83a32bd..0e68f559a9 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -31,6 +31,7 @@ import type { LocalTrack, TrackIndex, MarkerIndex, + IndexIntoFlowTable, ThreadsKey, NativeSymbolInfo, } from './profile-derived'; @@ -366,6 +367,7 @@ export type ProfileSpecificUrlState = { tabFilter: TabID | null; legacyThreadOrder: ThreadIndex[] | null; legacyHiddenThreads: ThreadIndex[] | null; + activeFlows: IndexIntoFlowTable[]; }; export type UrlState = { diff --git a/src/utils/fetch-assembly.ts b/src/utils/fetch-assembly.ts index eca1606a0a..e470a781c8 100644 --- a/src/utils/fetch-assembly.ts +++ b/src/utils/fetch-assembly.ts @@ -74,8 +74,8 @@ export async function fetchAssembly( // local symbol servers. Check the symbol server URL to avoid hammering the // official Mozilla symbolication server with requests it can't handle. // This check can be removed once it adds support for /asm/v1. -function _serverMightSupportAssembly(symbolServerUrl: string): boolean { - return isLocalURL(symbolServerUrl); +function _serverMightSupportAssembly(_symbolServerUrl: string): boolean { + return true; } // Convert the response from the JSON format into our own DecodedInstruction diff --git a/src/utils/window-console.ts b/src/utils/window-console.ts index 09a49f2691..82e5f03a2b 100644 --- a/src/utils/window-console.ts +++ b/src/utils/window-console.ts @@ -17,6 +17,10 @@ import { createBrowserConnection } from 'firefox-profiler/app-logic/browser-conn import { formatTimestamp } from 'firefox-profiler/utils/format-numbers'; import { togglePseudoStrategy } from 'firefox-profiler/components/app/AppLocalizationProvider'; import type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; +import { + printMarkerFlows, + printFlow, +} 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 @@ -30,6 +34,9 @@ export type ExtraPropertiesOnWindowForConsole = { callTree: CallTree; filteredMarkers: Marker[]; selectedMarker: Marker | null; + printFlows: () => void; + selectMarkerOnThread: (markerIndex: number, threadIndex: number) => void; + printFlow: (flowIndex: number) => void; experimental: { enableEventDelayTracks(): void; enableCPUGraphs(): void; @@ -105,6 +112,44 @@ export function addDataToWindowObject( }, }); + target.printFlows = function () { + const threadIndex = + selectorsForConsole.urlState.getFirstSelectedThreadIndex(getState()); + const markerIndex = + selectorsForConsole.selectedThread.getSelectedMarkerIndex(getState()); + const profileFlowInfo = + selectorsForConsole.flow.getProfileFlowInfo(getState()); + const threads = selectorsForConsole.profile.getThreads(getState()); + const fullMarkerListPerThread = + selectorsForConsole.flow.getFullMarkerListPerThread(getState()); + const stringTable = selectorsForConsole.profile.getStringTable(getState()); + if (markerIndex === null) { + console.log('No marker is selected.'); + } else { + printMarkerFlows( + threadIndex, + markerIndex, + profileFlowInfo, + threads, + fullMarkerListPerThread, + stringTable + ); + } + }; + target.selectMarkerOnThread = function (markerIndex, threadIndex) { + dispatch(actions.showProvidedThreads(new Set([threadIndex]))); + dispatch(actions.changeSelectedThreads(new Set([threadIndex]))); + dispatch(actions.changeSelectedMarker(threadIndex, markerIndex)); + }; + target.printFlow = function (flowIndex) { + const profileFlowInfo = + selectorsForConsole.flow.getProfileFlowInfo(getState()); + const threads = selectorsForConsole.profile.getThreads(getState()); + const fullMarkerListPerThread = + selectorsForConsole.flow.getFullMarkerListPerThread(getState()); + printFlow(flowIndex, profileFlowInfo, threads, fullMarkerListPerThread); + }; + target.experimental = { enableEventDelayTracks() { const areEventDelayTracksEnabled = dispatch(