Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs-developer/CHANGELOG-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ Note that this is not an exhaustive list. Processed profile format upgraders can

## Processed profile format

### Version 63

A new `SourceMapInfoTable` has been added to `profile.shared.sourceMapInfo`. Each entry maps a generated (compiled) position to its original source position as determined by source map symbolication.

- `originalSource: IndexIntoSourceTable[]`: original source file index. Set independently for both func entries (the function's definition file) and frame entries (the execution point's file, which can differ from the func's file for inlined code).
- `originalLine: number[]`: 1-based original line number
- `originalColumn: number[]`: 1-based original column number

Two new columns were added that index into this table:

- `FrameTable.sourceMapInfo: Array<IndexIntoSourceMapInfoTable | null>`: points to the original execution point for the frame
- `FuncTable.sourceMapInfo: Array<IndexIntoSourceMapInfoTable | null>`: points to the original definition site for the function

A new `content: Array<string | null>` column was added to `SourceTable`. It stores the original source file text from the source map's `sourcesContent` field, allowing offline source viewing when profiles are shared.

### Version 62

A new `display` field of type `CounterDisplayConfig` was added to `RawCounter`.
Expand Down
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const browserEnvConfig = {

globals: {
AVAILABLE_STAGING_LOCALES: null,
SOURCE_MAP_WORKER_PATH: '/source-map.worker.js',
},

snapshotFormat: {
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"@fluent/langneg": "^0.7.0",
"@fluent/react": "^0.15.2",
"@lezer/highlight": "^1.2.3",
"@lezer/javascript": "^1.5.4",
"@streamparser/json": "^0.0.22",
"@tgwf/co2": "^0.18.0",
"array-move": "^3.0.1",
Expand Down Expand Up @@ -109,6 +110,8 @@
"redux-logger": "^3.0.6",
"redux-thunk": "^3.1.0",
"reselect": "^4.1.8",
"source-map": "^0.7.6",
"url": "^0.11.4",
"valibot": "^1.4.0",
"workbox-window": "^7.4.1"
},
Expand Down
3 changes: 3 additions & 0 deletions scripts/build-profiler-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ const profilerCliConfig = {
define: {
__BUILD_HASH__: JSON.stringify(BUILD_HASH),
__VERSION__: JSON.stringify(version),
// SOURCE_MAP_WORKER_PATH is injected by the browser build; the CLI doesn't
// use source map workers but the shared code references this constant.
SOURCE_MAP_WORKER_PATH: JSON.stringify('/source-map.worker.js'),
},
external: [...nodeBaseConfig.external, 'gecko-profiler-demangle'],
};
Expand Down
24 changes: 21 additions & 3 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,32 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import esbuild from 'esbuild';

import { mainBundleConfig } from './lib/esbuild-configs.mjs';
import {
mainBundleConfig,
sourceMapWorkerConfig,
getSourceMapWorkerPath,
} from './lib/esbuild-configs.mjs';
import { cleanDist, saveMetafile } from './lib/build-utils.mjs';

async function build() {
cleanDist();
const buildResult = await esbuild.build(mainBundleConfig);

// Build the worker first so we can read its output path from the metafile
// and inject it into the main bundle via SOURCE_MAP_WORKER_PATH.
const workerResult = await esbuild.build(sourceMapWorkerConfig);

const buildResult = await esbuild.build({
...mainBundleConfig,
define: {
...mainBundleConfig.define,
SOURCE_MAP_WORKER_PATH: JSON.stringify(
getSourceMapWorkerPath(workerResult.metafile)
),
},
});

saveMetafile(buildResult);
console.log('✅ Main browser build completed');
console.log('✅ Main browser build and source map worker completed');
}

build().catch(console.error);
11 changes: 10 additions & 1 deletion scripts/lib/dev-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export async function startDevServer(buildConfig, options = {}) {
fallback = 'index.html',
onServerStart,
cleanDist = true,
extraWatchConfigs = [],
} = options;

// Clean dist directory first
Expand All @@ -77,6 +78,12 @@ export async function startDevServer(buildConfig, options = {}) {
// Start watching for changes
await buildContext.watch();

// Watch extra configs (no serving needed, just watch for rebuilds)
const extraContexts = await Promise.all(
extraWatchConfigs.map((config) => esbuild.context(config))
);
await Promise.all(extraContexts.map((ctx) => ctx.watch()));

// Create HTTP server
const server = http.createServer((req, res) => {
// Validate Host header
Expand Down Expand Up @@ -135,7 +142,9 @@ export async function startDevServer(buildConfig, options = {}) {
isShuttingDown = true;

console.log('\nShutting down...');
await buildContext.dispose();
await Promise.all(
[buildContext, ...extraContexts].map((ctx) => ctx.dispose())
);
server.close();
process.exit(0);
});
Expand Down
36 changes: 36 additions & 0 deletions scripts/lib/esbuild-configs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ export const mainBundleConfig = {
: 'undefined',
// no need to define NODE_ENV:
// esbuild automatically defines NODE_ENV based on the value for "minify"
// In dev, the worker is not hashed so the path is predictable.
// In production, build.mjs overrides this after building the worker first.
SOURCE_MAP_WORKER_PATH: JSON.stringify('/source-map.worker.js'),
},
external: ['zlib'],
plugins: [
Expand All @@ -98,6 +101,10 @@ export const mainBundleConfig = {
{ from: ['res/img/favicon.png'], to: ['dist/res/img'] },
{ from: ['docs-user/**/*'], to: ['dist/docs'] },
{ from: ['locales/**/*'], to: ['dist/locales'] },
{
from: ['node_modules/source-map/lib/mappings.wasm'],
to: ['dist'],
},
],
}),
generateHtmlPlugin({
Expand All @@ -108,6 +115,35 @@ export const mainBundleConfig = {
],
};

// Source map worker bundle configuration.
// Built as a standalone IIFE so that npm dependencies (lezer, source-map) are
// bundled into a single file that can be loaded as a Web Worker without needing
// ES module support. In production the output filename includes a content hash
// (e.g. source-map-ABCD1234.worker.js); the path is then injected into the main
// bundle via the SOURCE_MAP_WORKER_PATH define. In dev there is no hash since the
// dev server always serves fresh content and the define can't be updated mid-watch.
export const sourceMapWorkerConfig = {
...baseConfig,
entryPoints: ['src/profile-logic/source-map.worker.ts'],
outdir: 'dist',
format: 'iife',
platform: 'browser',
target: browserslistToEsbuild(),
sourcemap: true,
splitting: false,
entryNames: isProduction ? '[name]-[hash]' : '[name]',
metafile: true,
plugins: [wasmLoader()],
};

export function getSourceMapWorkerPath(metafile) {
const [entryPoint] = sourceMapWorkerConfig.entryPoints;
const [outputPath] = Object.entries(metafile.outputs).find(
([, output]) => output.entryPoint === entryPoint
);
return '/' + path.basename(outputPath);
}

// Photon styling build configuration
const photonTemplateHTML = fs.readFileSync(
path.join(projectRoot, 'res', 'photon', 'index.html'),
Expand Down
6 changes: 5 additions & 1 deletion scripts/run-dev-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
* 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 path from 'path';
import { mainBundleConfig } from './lib/esbuild-configs.mjs';
import {
mainBundleConfig,
sourceMapWorkerConfig,
} from './lib/esbuild-configs.mjs';
import { startDevServer } from './lib/dev-server.mjs';
import { serveAndOpenProfile } from './lib/profile-server.mjs';
import yargs from 'yargs';
Expand All @@ -22,6 +25,7 @@ startDevServer(mainBundleConfig, {
host,
distDir: 'dist',
cleanDist: true,
extraWatchConfigs: [sourceMapWorkerConfig],
onServerStart: (profilerUrl) => {
const barAscii =
'------------------------------------------------------------------------------------------';
Expand Down
135 changes: 134 additions & 1 deletion src/actions/receive-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ import { expandUrl } from 'firefox-profiler/utils/shorten-url';
import { TemporaryError } from 'firefox-profiler/utils/errors';
import {
getSelectedThreadIndexesOrNull,
getGlobalTracks,
getGlobalTrackOrder,
getHiddenGlobalTracks,
getHiddenLocalTracksByPid,
getLocalTracksByPid,
getLocalTrackOrderByPid,
getLegacyThreadOrder,
getLegacyHiddenThreads,
Expand Down Expand Up @@ -75,8 +77,11 @@ import { batchLoadDataUrlIcons } from './icons';
import {
determineTimelineType,
hasUsefulSamples,
collectSourceIndicesFromThreads,
} from 'firefox-profiler/profile-logic/profile-data';
import { doSourceMapSymbolication } from './source-map-symbolication';

import type { RawSourceMap } from 'source-map';
import type {
RequestedLib,
ImplementationFilter,
Expand All @@ -89,6 +94,7 @@ import type {
TabID,
PageList,
MixedObject,
IndexIntoSourceTable,
} from 'firefox-profiler/types';

import type { SymbolicationStepInfo } from '../profile-logic/symbolication';
Expand Down Expand Up @@ -245,7 +251,50 @@ export function finalizeProfileView(
}
}

await Promise.all([faviconsPromise, symbolicationPromise]);
// Fetch source maps for JS sources in visible threads, then apply JS
// symbolication. Fetching runs in parallel with native symbolication, but
// the worker is only dispatched after native symbolication has committed
// its Redux changes, so neither clobbers the other's funcTable updates.
// Requires WebChannel version 7+.
let sourceMapSymbolicationPromise: Promise<void> | null = null;
if (
browserConnection !== null &&
browserConnection.supportsSourceMapFetching()
) {
const tracksWithOrder = {
globalTracks: getGlobalTracks(getState()),
globalTrackOrder: getGlobalTrackOrder(getState()),
localTracksByPid: getLocalTracksByPid(getState()),
localTrackOrderByPid: getLocalTrackOrderByPid(getState()),
};
const hiddenTracks = {
hiddenGlobalTracks: getHiddenGlobalTracks(getState()),
hiddenLocalTracksByPid: getHiddenLocalTracksByPid(getState()),
};
const visibleThreadIndexes = getVisibleThreads(
tracksWithOrder,
hiddenTracks
);
// Fetch source maps concurrently with native symbolication. Once both
// have completed, apply JS symbolication on top of the final state.
sourceMapSymbolicationPromise = Promise.all([
doResolveSourceMaps(
profile,
visibleThreadIndexes,
browserConnection,
dispatch
),
symbolicationPromise,
]).then(([{ resolvedSourceMaps, compiledSources }]) =>
dispatch(doSourceMapSymbolication(resolvedSourceMaps, compiledSources))
);
}

await Promise.all([
faviconsPromise,
symbolicationPromise,
sourceMapSymbolicationPromise,
]);
};
}

Expand Down Expand Up @@ -811,6 +860,90 @@ export async function doSymbolicateProfile(
dispatch(doneSymbolicating());
}

/**
* Resolve JS source maps for all sources referenced by visible threads.
* Fetches source maps via the browser WebChannel.
*
* Also fetches the compiled source text which is required by the scope-tree
* name resolution in symbolicateWithSourceMaps.
*/
async function doResolveSourceMaps(
profile: Profile,
visibleThreadIndexes: ThreadIndex[],
browserConnection: BrowserConnection,
dispatch: Dispatch
): Promise<{
resolvedSourceMaps: Map<IndexIntoSourceTable, RawSourceMap>;
compiledSources: Map<IndexIntoSourceTable, string>;
}> {
const { sources, stringArray } = profile.shared;

// Collect source indexes from visible threads that have a sourceMapURL and a
// UUID. Only UUID-bearing sources can be fetched via the browser WebChannel.
const sourceIndexesWithSourceMaps = new Set<IndexIntoSourceTable>();
for (const sourceIndex of collectSourceIndicesFromThreads(
visibleThreadIndexes,
profile.threads,
profile.shared
)) {
if (
sources.sourceMapURL[sourceIndex] !== null &&
sources.sourceMapURL[sourceIndex] !== undefined &&
typeof sources.id[sourceIndex] === 'string'
) {
sourceIndexesWithSourceMaps.add(sourceIndex);
}
}

if (sourceIndexesWithSourceMaps.size === 0) {
return { resolvedSourceMaps: new Map(), compiledSources: new Map() };
}

// Fetch source maps and compiled sources in parallel, ignoring individual failures.
const resolvedSourceMaps: Map<IndexIntoSourceTable, RawSourceMap> = new Map();
const compiledSources: Map<IndexIntoSourceTable, string> = new Map();

dispatch({ type: 'START_SOURCE_MAP_FETCHING' });
try {
await Promise.all(
Array.from(sourceIndexesWithSourceMaps).map(async (sourceIndex) => {
const filename = stringArray[sources.filename[sourceIndex]];
// sourceId is guaranteed non-null by the filter above.
const sourceId = sources.id[sourceIndex] as string;

await Promise.all([
browserConnection
.getSourceMap(sourceId)
.then((result) => {
resolvedSourceMaps.set(sourceIndex, result);
})
.catch((e) => {
console.warn(
`Failed to fetch source map for "${filename}" (id=${sourceId}):`,
e
);
}),
browserConnection
.getJSSource(sourceId)
.then((text) => {
compiledSources.set(sourceIndex, text);
})
.catch((e) => {
console.warn(
`Failed to fetch compiled source for "${filename}" (id=${sourceId}):`,
e
);
}),
]);
})
);
} finally {
dispatch({ type: 'DONE_SOURCE_MAP_FETCHING' });
}

return { resolvedSourceMaps, compiledSources };
}

export async function retrievePageFaviconsFromBrowser(
dispatch: Dispatch,
pages: PageList,
Expand Down
Loading
Loading