Improve API to take JS heap snapshots in Fantom (#53071)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/53071

Changelog: [internal]

The current API to take JS heap snapshots has some problems:
1. Ergonomics: it requires you to input the filepath where you want to store the snapshot. This isn't aligned with the behavior we have for JS traces where the output path is provided to you.
2. It doesn't work in optimized builds, as it requires a specific option in Hermes.

For 1), this replaces `Fantom.saveJSMemoryHeapSnapshot(filePath)` with `Fantom.takeJSMemoryHeapSnapshot()` that outputs the snapshot in a predefined path and prints it to the console.

For 2), this adds a new environment variable to force building Hermes with memory instrumentation (`FANTOM_ENABLE_JS_MEMORY_INSTRUMENTATION`). This is exposed as an option and not set by default because it has a performance overhead at runtime that we don't want to pay (especially in benchmarks).

This option only works when using Buck in development, because we want to generate this new binary type on demand when necessary, instead of making it part of the prebuilts we do before running tests in OSS and CI.

Reviewed By: lenaic

Differential Revision: D79642314

fbshipit-source-id: a2980616a495bd6dca29c0709a9581db6fb3f2cc
This commit is contained in:
Rubén Norte
2025-08-06 05:40:02 -07:00
committed by Facebook GitHub Bot
parent 2187f653f6
commit 789fc57254
7 changed files with 127 additions and 18 deletions
@@ -17,6 +17,7 @@ const VALID_ENVIRONMENT_VARIABLES = [
'FANTOM_LOG_COMMANDS',
'FANTOM_PRINT_OUTPUT',
'FANTOM_PROFILE_JS',
'FANTOM_ENABLE_JS_MEMORY_INSTRUMENTATION',
];
/**
@@ -63,6 +64,10 @@ export const forceTestModeForBenchmarks: boolean = Boolean(
export const profileJS: boolean = Boolean(process.env.FANTOM_PROFILE_JS);
export const enableJSMemoryInstrumentation: boolean = Boolean(
process.env.FANTOM_ENABLE_JS_MEMORY_INSTRUMENTATION,
);
/**
* Throws an error if there is an environment variable defined with the FANTOM_
* prefix that is not recognized.
@@ -78,4 +83,15 @@ export function validateEnvironmentVariables(): void {
);
}
}
// Enabling memory instrumentation is only necessary when taking JS heap
// snapshots in optimized builds (where it is disabled by default).
// This isn't supported in CI or in OSS because that would require adding
// another dimension to the build matrix, duplicating the number of binaries
// we need to build before starting test execution.
if ((isCI || isOSS) && enableJSMemoryInstrumentation) {
throw new Error(
'Memory instrumentation is not supported in CI or OSS environments, as it requires a custom Hermes build that is not prebuilt in those environments.',
);
}
}
@@ -22,12 +22,16 @@ module.exports = function entrypointTemplate({
testConfig,
snapshotConfig,
jsTraceOutputPath,
jsHeapSnapshotOutputPathTemplate,
jsHeapSnapshotOutputPathTemplateToken,
}: {
testPath: string,
setupModulePath: string,
featureFlagsModulePath: string,
testConfig: FantomTestConfig,
snapshotConfig: SnapshotConfig,
jsHeapSnapshotOutputPathTemplate: string,
jsHeapSnapshotOutputPathTemplateToken: string,
jsTraceOutputPath: ?string,
}): string {
const constants: FantomRuntimeConstants = {
@@ -36,6 +40,8 @@ module.exports = function entrypointTemplate({
forceTestModeForBenchmarks: EnvironmentOptions.forceTestModeForBenchmarks,
fantomConfigSummary: formatFantomConfig(testConfig),
jsTraceOutputPath,
jsHeapSnapshotOutputPathTemplate,
jsHeapSnapshotOutputPathTemplateToken,
};
return `/**
+42 -4
View File
@@ -23,6 +23,10 @@ export const JS_TRACES_OUTPUT_PATH: string = path.join(
OUTPUT_PATH,
'js-traces',
);
export const JS_HEAP_SNAPSHOTS_OUTPUT_PATH: string = path.join(
OUTPUT_PATH,
'js-heap-snapshots',
);
export function getTestBuildOutputPath(): string {
const fantomRunID = process.env.__FANTOM_RUN_ID__;
@@ -35,14 +39,18 @@ export function getTestBuildOutputPath(): string {
return path.join(JS_BUILD_OUTPUT_PATH, fantomRunID);
}
export function buildJSTracesOutputPath(
export function buildJSTracesOutputPath({
testPath,
testConfig,
isMultiConfigTest,
}: {
testPath: string,
testConfig: FantomTestConfig,
isMultiConfig: boolean,
): string {
isMultiConfigTest: boolean,
}): string {
const fileNameParts = [path.basename(testPath)];
if (isMultiConfig) {
if (isMultiConfigTest) {
const configSummary = formatFantomConfig(testConfig, {style: 'short'});
if (configSummary !== '') {
fileNameParts.push(configSummary);
@@ -55,3 +63,33 @@ export function buildJSTracesOutputPath(
return path.join(JS_TRACES_OUTPUT_PATH, fileName);
}
const JS_HEAP_SNAPSHOT_OUTPUT_PATH_TOKEN = '${timestamp}';
export function buildJSHeapSnapshotsOutputPathTemplate({
testPath,
testConfig,
isMultiConfigTest,
}: {
testPath: string,
testConfig: FantomTestConfig,
isMultiConfigTest: boolean,
}): [string, string] {
const fileNameParts = [path.basename(testPath)];
if (isMultiConfigTest) {
const configSummary = formatFantomConfig(testConfig, {style: 'short'});
if (configSummary !== '') {
fileNameParts.push(configSummary);
}
}
fileNameParts.push(JS_HEAP_SNAPSHOT_OUTPUT_PATH_TOKEN);
const fileName = fileNameParts.join('-') + '.heapsnapshot';
return [
path.join(JS_HEAP_SNAPSHOTS_OUTPUT_PATH, fileName),
JS_HEAP_SNAPSHOT_OUTPUT_PATH_TOKEN,
];
}
+19 -1
View File
@@ -30,7 +30,9 @@ import {run as runFantomTester} from './executables/tester';
import formatFantomConfig from './formatFantomConfig';
import getFantomTestConfigs from './getFantomTestConfigs';
import {
JS_HEAP_SNAPSHOTS_OUTPUT_PATH,
JS_TRACES_OUTPUT_PATH,
buildJSHeapSnapshotsOutputPathTemplate,
buildJSTracesOutputPath,
getTestBuildOutputPath,
} from './paths';
@@ -56,6 +58,7 @@ import readline from 'readline';
const TEST_BUILD_OUTPUT_PATH = getTestBuildOutputPath();
fs.mkdirSync(TEST_BUILD_OUTPUT_PATH, {recursive: true});
fs.mkdirSync(JS_HEAP_SNAPSHOTS_OUTPUT_PATH, {recursive: true});
if (EnvironmentOptions.profileJS) {
fs.mkdirSync(JS_TRACES_OUTPUT_PATH, {recursive: true});
@@ -280,9 +283,22 @@ module.exports = async function runTest(
}
const jsTraceOutputPath = EnvironmentOptions.profileJS
? buildJSTracesOutputPath(testPath, testConfig, testConfigs.length > 1)
? buildJSTracesOutputPath({
testPath,
testConfig,
isMultiConfigTest: testConfigs.length > 1,
})
: null;
const [
jsHeapSnapshotOutputPathTemplate,
jsHeapSnapshotOutputPathTemplateToken,
] = buildJSHeapSnapshotsOutputPathTemplate({
testPath,
testConfig,
isMultiConfigTest: testConfigs.length > 1,
});
const entrypointContents = entrypointTemplate({
testPath: `${path.relative(TEST_BUILD_OUTPUT_PATH, testPath)}`,
setupModulePath: `${path.relative(TEST_BUILD_OUTPUT_PATH, setupModulePath)}`,
@@ -292,6 +308,8 @@ module.exports = async function runTest(
updateSnapshot: snapshotState._updateSnapshot,
data: getInitialSnapshotData(snapshotState),
},
jsHeapSnapshotOutputPathTemplate,
jsHeapSnapshotOutputPathTemplateToken,
jsTraceOutputPath,
});
+6 -3
View File
@@ -27,13 +27,16 @@ export enum HermesVariant {
export function getBuckOptionsForHermes(
variant: HermesVariant,
): $ReadOnlyArray<string> {
const baseOptions = EnvironmentOptions.enableJSMemoryInstrumentation
? ['-c hermes.memory_instrumentation=true']
: [];
switch (variant) {
case HermesVariant.Hermes:
return [];
return baseOptions;
case HermesVariant.StaticHermesStable:
return ['-c hermes.static_hermes=stable'];
return [...baseOptions, '-c hermes.static_hermes=stable'];
case HermesVariant.StaticHermesExperimental:
return ['-c hermes.static_hermes=trunk'];
return [...baseOptions, '-c hermes.static_hermes=trunk'];
}
}
+4
View File
@@ -13,6 +13,8 @@ export type FantomRuntimeConstants = $ReadOnly<{
isRunningFromCI: boolean,
forceTestModeForBenchmarks: boolean,
fantomConfigSummary: string,
jsHeapSnapshotOutputPathTemplate: string,
jsHeapSnapshotOutputPathTemplateToken: string,
jsTraceOutputPath: ?string,
}>;
@@ -21,6 +23,8 @@ let constants: FantomRuntimeConstants = {
isRunningFromCI: false,
forceTestModeForBenchmarks: false,
fantomConfigSummary: '',
jsHeapSnapshotOutputPathTemplate: '',
jsHeapSnapshotOutputPathTemplateToken: '',
jsTraceOutputPath: null,
};
+34 -10
View File
@@ -666,20 +666,44 @@ export function createShadowNodeRevisionGetter(
/**
* Saves a heap snapshot after forcing garbage collection.
*
* The heapsnapshot is saved to the filename supplied as an argument.
* If a relative path is supplied, it will be saved relative to where you are invoking the tests.
*
* The supplied filename should end in .heapsnapshot, and it can be opened
* using the "Memory" pane in Chrome DevTools.
*
* @param filepath - File where JS memory heap will be saved.
* It prints the location of the saved snapshot file, which can be opened using
* the "Memory" pane in Chrome DevTools.
*/
export function saveJSMemoryHeapSnapshot(filePath: string): void {
if (getConstants().isRunningFromCI) {
export function takeJSMemoryHeapSnapshot(): void {
const constants = getConstants();
if (constants.isRunningFromCI) {
throw new Error('Unexpected call to `saveJSMemoryHeapSnapshot` from CI');
}
NativeFantom.saveJSMemoryHeapSnapshot(filePath);
const filePath = constants.jsHeapSnapshotOutputPathTemplate.replace(
constants.jsHeapSnapshotOutputPathTemplateToken,
new Date().toISOString(),
);
try {
NativeFantom.saveJSMemoryHeapSnapshot(filePath);
} catch (nativeError: mixed) {
let errorMessage = 'Error saving JS heap snapshot.';
if (
nativeError instanceof Error &&
nativeError.message.includes(
"Cannot create heap snapshots if Hermes isn't built with memory instrumentation.",
)
) {
// We would generally use an error with nativeError as cause, but our infra
// doesn't support that yet (it expects a `cause` property on the error with
// a very specific shape).
errorMessage +=
' If you want to take JS heap snapshots in optimized builds, ' +
'please call Fantom with FANTOM_ENABLE_MEMORY_INSTRUMENTATION=1 ' +
'(only works locally with Buck).';
}
throw new Error(errorMessage, {cause: nativeError});
}
console.info(`💾 JS heap snapshot saved to ${filePath}\n`);
}
export * from './HighResTimeStampMock';