diff --git a/packages/metro-config/src/defaults/index.js b/packages/metro-config/src/defaults/index.js index 95a4a0ead6..8ee4308daf 100644 --- a/packages/metro-config/src/defaults/index.js +++ b/packages/metro-config/src/defaults/index.js @@ -70,6 +70,7 @@ const getDefaultValues = (projectRoot: ?string): ConfigT => ({ customSerializer: null, isThirdPartyModule: module => /(?:^|[/\\])node_modules[/\\]/.test(module.path), + unstable_allowIndexMap: false, }, server: { diff --git a/packages/metro-config/src/types.js b/packages/metro-config/src/types.js index e86aaa3620..8d4ca510c8 100644 --- a/packages/metro-config/src/types.js +++ b/packages/metro-config/src/types.js @@ -147,6 +147,13 @@ type SerializerConfigT = { polyfillModuleNames: ReadonlyArray, processModuleFilter: (modules: Module<>) => boolean, isThirdPartyModule: (module: Readonly<{path: string, ...}>) => boolean, + // When source maps are stored compactly as VLQ (see + // `transformer.unstable_compactSourceMaps`), allow the whole-bundle map to be + // emitted as an index map (sectioned) that passes the VLQ through verbatim, + // instead of decoding + re-encoding into a flat map. Cheaper to serialize, but + // requires consumers that understand index source maps. No-op unless compact + // VLQ maps are actually present, and ignored when a `customSerializer` is set. + unstable_allowIndexMap: boolean, }; type TransformerConfigT = { diff --git a/packages/metro-config/types/types.d.ts b/packages/metro-config/types/types.d.ts index 3d7a2daa0b..8cb30693e0 100644 --- a/packages/metro-config/types/types.d.ts +++ b/packages/metro-config/types/types.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<9c62bc2ca711f9693edc135a382a382a>> + * @generated SignedSource<<926fc453e7c2af496911a003ca20e556>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-config/src/types.js @@ -140,6 +140,7 @@ type SerializerConfigT = { polyfillModuleNames: ReadonlyArray; processModuleFilter: (modules: Module) => boolean; isThirdPartyModule: (module: Readonly<{path: string}>) => boolean; + unstable_allowIndexMap: boolean; }; type TransformerConfigT = Omit< JsTransformerConfig, diff --git a/packages/metro-source-map/src/BundleBuilder.js b/packages/metro-source-map/src/BundleBuilder.js index 3606e44bd6..9fdd1d7252 100644 --- a/packages/metro-source-map/src/BundleBuilder.js +++ b/packages/metro-source-map/src/BundleBuilder.js @@ -113,12 +113,12 @@ function measureString(str: string): { } export function createIndexMap( - file: string, + file: ?string, sections: Array, ): IndexMap { return { version: 3, - file, + ...(file != null ? {file} : null), sections, }; } diff --git a/packages/metro-source-map/src/__tests__/source-map-test.js b/packages/metro-source-map/src/__tests__/source-map-test.js index f5499ca5cb..29817e6834 100644 --- a/packages/metro-source-map/src/__tests__/source-map-test.js +++ b/packages/metro-source-map/src/__tests__/source-map-test.js @@ -9,11 +9,17 @@ * @oncall react_native */ -import type {BabelDecodedMap, MetroSourceMapSegmentTuple} from '../source-map'; +import type { + BabelDecodedMap, + IndexMap, + MetroSourceMapSegmentTuple, + MixedSourceMap, +} from '../source-map'; import Generator from '../Generator'; import { fromRawMappings, + fromRawMappingsIndexed, isVlqMap, toBabelSegments, toSegmentTuple, @@ -291,6 +297,105 @@ describe('fromRawMappings with VlqMap', () => { }); }); +describe('fromRawMappingsIndexed', () => { + // fromRawMappingsIndexed always yields an indexed (sectioned) map. + const asIndexMap = (map: MixedSourceMap): IndexMap => { + // eslint-disable-next-line lint/strictly-null + if (map.mappings !== undefined) { + throw new Error('Expected an indexed source map'); + } + return map; + }; + + test('produces an indexed map, passing VLQ through verbatim', () => { + const input = [ + { + code: lines(11), + functionMap: null, + map: makeVlqMap('E;;IAKMA;;;;QAII;;;;YAIIC', ['apples', 'pears']), + source: 'code1', + path: 'path1', + isIgnored: false, + }, + { + code: lines(3), + functionMap: null, + map: makeVlqMap('E;;IAegBA', ['bananas']), + source: 'code2', + path: 'path2', + isIgnored: true, + }, + ]; + + const map = asIndexMap(fromRawMappingsIndexed(input).toMap()); + expect(map.version).toBe(3); + expect(map.sections).toHaveLength(2); + + const [s0, s1] = map.sections; + expect(s0.offset).toEqual({line: 0, column: 0}); + expect(s0.map.sources).toEqual(['path1']); + expect(s0.map.sourcesContent).toEqual(['code1']); + // VLQ string passes through unchanged (no decode/re-encode). + expect(s0.map.mappings).toBe('E;;IAKMA;;;;QAII;;;;YAIIC'); + expect(s0.map.names).toEqual(['apples', 'pears']); + + expect(s1.offset).toEqual({line: 11, column: 0}); + expect(s1.map.mappings).toBe('E;;IAegBA'); + expect(s1.map.x_google_ignoreList).toEqual([0]); + }); + + test('preserves functionMap as per-section x_facebook_sources', () => { + const functionMap = {names: [''], mappings: 'AAA'}; + const map = asIndexMap( + fromRawMappingsIndexed([ + { + code: 'x\n', + functionMap, + map: makeVlqMap('AAAA', []), + source: 'src', + path: 'file.js', + isIgnored: false, + }, + ]).toMap(), + ); + expect(map.sections[0].map.x_facebook_sources).toEqual([[functionMap]]); + }); + + test('toString produces valid indexed JSON', () => { + const parsed = JSON.parse( + fromRawMappingsIndexed([ + { + code: 'x\n', + functionMap: null, + map: makeVlqMap('AAAA', []), + source: 'src', + path: 'file.js', + isIgnored: false, + }, + ]).toString(), + ); + expect(parsed.version).toBe(3); + expect(parsed.sections).toHaveLength(1); + expect(parsed.sections[0].map.mappings).toBe('AAAA'); + }); + + test('excludeSource omits sourcesContent', () => { + const map = asIndexMap( + fromRawMappingsIndexed([ + { + code: 'x\n', + functionMap: null, + map: makeVlqMap('AAAA', []), + source: 'src', + path: 'file.js', + isIgnored: false, + }, + ]).toMap(undefined, {excludeSource: true}), + ); + expect(map.sections[0].map.sourcesContent).toBeUndefined(); + }); +}); + describe('vlqMapFromTuples', () => { // Decode via Metro's existing string->tuples path, the inverse of // vlqMapFromTuples. diff --git a/packages/metro-source-map/src/source-map.js b/packages/metro-source-map/src/source-map.js index 6bed2ab9e3..6212375958 100644 --- a/packages/metro-source-map/src/source-map.js +++ b/packages/metro-source-map/src/source-map.js @@ -139,6 +139,45 @@ export type RawMappingsModule = { readonly lineCount?: number, }; +// Common shape of the flat `Generator` and the indexed `IndexedSourceMapResult`, +// so serializers can hold either and call `toMap`/`toString` uniformly. +export interface SourceMapGenerator { + toMap(file?: string, options?: {excludeSource?: boolean}): MixedSourceMap; + toString(file?: string, options?: {excludeSource?: boolean}): string; +} + +/** + * Result of `fromRawMappingsIndexed`: a sectioned (indexed) source map where + * each module is one section. VLQ-stored modules pass through verbatim, which is + * why building this is cheap compared to flattening into a single map. + */ +class IndexedSourceMapResult implements SourceMapGenerator { + #sections: Array; + + constructor(sections: Array) { + this.#sections = sections; + } + + toMap(file?: string, options?: {excludeSource?: boolean}): MixedSourceMap { + const sections = + options?.excludeSource === true + ? this.#sections.map(section => { + // exclude source + const {sourcesContent: _, ...map} = section.map; + return { + ...section, + map, + }; + }) + : this.#sections; + return createIndexMap(file, sections); + } + + toString(file?: string, options?: {excludeSource?: boolean}): string { + return JSON.stringify(this.toMap(file, options)); + } +} + function isVlqMap( map: ?ReadonlyArray | VlqMap, ): implies map is VlqMap { @@ -241,6 +280,68 @@ async function fromRawMappingsNonBlocking( }); } +/** + * Like `fromRawMappings`, but produces an indexed (sectioned) source map with + * one section per module. VLQ-stored modules pass through verbatim — no + * decode/re-encode — which is the whole point: it's much cheaper to serialize + * than the flat path, at the cost of emitting an indexed map that consumers must + * understand. Per-module work is trivial, so this runs synchronously. + */ +function fromRawMappingsIndexed( + modules: ReadonlyArray, + offsetLines: number = 0, +): IndexedSourceMapResult { + const sections: Array = []; + let carryOver = offsetLines; + + for (const mod of modules) { + if (mod.map != null) { + sections.push({ + offset: {line: carryOver, column: 0}, + map: toIndexMapSection(mod), + }); + } + carryOver = carryOver + countLines(mod.code); + } + + return new IndexedSourceMapResult(sections); +} + +/** + * Builds a single section of an indexed source map. VLQ maps pass through + * verbatim, while tuple maps are encoded with a fresh per-section Generator. + */ +function toIndexMapSection(module: RawMappingsModule): BasicSourceMap { + const {map, path, source, functionMap, isIgnored} = module; + + if (isVlqMap(map)) { + let sectionMap: BasicSourceMap = { + version: 3, + sources: [path], + sourcesContent: [source], + names: [...map.names], + mappings: map.mappings, + }; + // The Generator bakes these in for tuple maps; for passthrough VLQ maps we + // have to attach them ourselves. + if (functionMap != null) { + sectionMap = {...sectionMap, x_facebook_sources: [[functionMap]]}; + } + if (isIgnored) { + sectionMap = {...sectionMap, x_google_ignoreList: [0]}; + } + return sectionMap; + } + + if (Array.isArray(map)) { + const generator = new Generator(); + addMappingsForFile(generator, map, module, 0); + return generator.toMap(); + } + + throw new Error(`Unexpected module with full source map found: ${path}`); +} + /** * Transforms a standard source map object into a Raw Mappings object, to be * used across the bundler. @@ -504,6 +605,7 @@ export { createIndexMap, generateFunctionMap, fromRawMappings, + fromRawMappingsIndexed, fromRawMappingsNonBlocking, functionMapBabelPlugin, isVlqMap, diff --git a/packages/metro-source-map/types/BundleBuilder.d.ts b/packages/metro-source-map/types/BundleBuilder.d.ts index f4aeef6207..dfc6f75bcd 100644 --- a/packages/metro-source-map/types/BundleBuilder.d.ts +++ b/packages/metro-source-map/types/BundleBuilder.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<920bacbb8042b15a2cd4888e0ca47b8c>> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-source-map/src/BundleBuilder.js @@ -37,6 +37,6 @@ export declare class BundleBuilder { getCode(): string; } export declare function createIndexMap( - file: string, + file: null | undefined | string, sections: Array, ): IndexMap; diff --git a/packages/metro-source-map/types/source-map.d.ts b/packages/metro-source-map/types/source-map.d.ts index dcc9aec967..cc1baa2e49 100644 --- a/packages/metro-source-map/types/source-map.d.ts +++ b/packages/metro-source-map/types/source-map.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<9ec89353742743678e422f0bf81e488d>> + * @generated SignedSource<<13fbae6a38a28c6a6e3a2be58804c33d>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-source-map/src/source-map.js @@ -107,6 +107,20 @@ export type RawMappingsModule = { readonly isIgnored: boolean; readonly lineCount?: number; }; +export interface SourceMapGenerator { + toMap(file?: string, options?: {excludeSource?: boolean}): MixedSourceMap; + toString(file?: string, options?: {excludeSource?: boolean}): string; +} +/** + * Result of `fromRawMappingsIndexed`: a sectioned (indexed) source map where + * each module is one section. VLQ-stored modules pass through verbatim, which is + * why building this is cheap compared to flattening into a single map. + */ +declare class IndexedSourceMapResult implements SourceMapGenerator { + constructor(sections: Array); + toMap(file?: string, options?: {excludeSource?: boolean}): MixedSourceMap; + toString(file?: string, options?: {excludeSource?: boolean}): string; +} declare function isVlqMap( map: (null | undefined | ReadonlyArray) | VlqMap, ): map is VlqMap; @@ -125,6 +139,17 @@ declare function fromRawMappingsNonBlocking( modules: ReadonlyArray, offsetLines?: number, ): Promise; +/** + * Like `fromRawMappings`, but produces an indexed (sectioned) source map with + * one section per module. VLQ-stored modules pass through verbatim — no + * decode/re-encode — which is the whole point: it's much cheaper to serialize + * than the flat path, at the cost of emitting an indexed map that consumers must + * understand. Per-module work is trivial, so this runs synchronously. + */ +declare function fromRawMappingsIndexed( + modules: ReadonlyArray, + offsetLines?: number, +): IndexedSourceMapResult; /** * Transforms a standard source map object into a Raw Mappings object, to be * used across the bundler. @@ -183,6 +208,7 @@ export { createIndexMap, generateFunctionMap, fromRawMappings, + fromRawMappingsIndexed, fromRawMappingsNonBlocking, functionMapBabelPlugin, isVlqMap, diff --git a/packages/metro/src/DeltaBundler/Serializers/__tests__/sourceMapString-test.js b/packages/metro/src/DeltaBundler/Serializers/__tests__/sourceMapString-test.js index 782cdc1aed..c7cd9194b7 100644 --- a/packages/metro/src/DeltaBundler/Serializers/__tests__/sourceMapString-test.js +++ b/packages/metro/src/DeltaBundler/Serializers/__tests__/sourceMapString-test.js @@ -199,3 +199,101 @@ describe.each([sourceMapString, sourceMapStringNonBlocking])( }); }, ); + +describe.each([sourceMapString, sourceMapStringNonBlocking])( + 'allowIndexMap (%p)', + sourceMapStringImpl => { + const vlqModule: Module<> = { + path: '/root/vlq.js', + dependencies: new Map(), + inverseDependencies: new CountingSet(), + getSource: () => Buffer.from('source vlq'), + output: [ + { + type: 'js/module', + data: { + code: '__d(function() {/* code for vlq */});', + lineCount: 1, + // Stored compactly as VLQ rather than decoded tuples. + map: {mappings: 'AAAA', names: []}, + functionMap: {names: [''], mappings: 'AAA'}, + }, + }, + ], + }; + + const options = { + excludeSource: false, + processModuleFilter: (module: Module<>) => true, + shouldAddToIgnoreList: (module: Module<>) => false, + getSourceUrl: null, + }; + + test('emits an indexed map passing VLQ through verbatim when enabled', async () => { + const parsed = JSON.parse( + await sourceMapStringImpl([fooModule, vlqModule], { + ...options, + allowIndexMap: true, + }), + ); + expect(parsed.version).toBe(3); + expect(parsed.sections).toHaveLength(2); + // VLQ module passes through unchanged. + expect(parsed.sections[1].offset).toEqual({line: 1, column: 0}); + expect(parsed.sections[1].map.mappings).toBe('AAAA'); + expect(parsed.sections[1].map.sources).toEqual(['/root/vlq.js']); + expect(parsed.sections[1].map.sourcesContent).toEqual(['source vlq']); + expect(parsed.sections[1].map.x_facebook_sources).toEqual([ + [{names: [''], mappings: 'AAA'}], + ]); + }); + + test('falls back to a flat map when no VLQ maps are present', async () => { + const parsed = JSON.parse( + await sourceMapStringImpl([fooModule, barModule], { + ...options, + allowIndexMap: true, + }), + ); + // No VLQ → indexed emit is pointless, so we stay flat. + expect(parsed.sections).toBeUndefined(); + expect(typeof parsed.mappings).toBe('string'); + }); + + test('emits a flat map for VLQ input when disabled', async () => { + const parsed = JSON.parse( + await sourceMapStringImpl([fooModule, vlqModule], { + ...options, + allowIndexMap: false, + }), + ); + expect(parsed.sections).toBeUndefined(); + expect(typeof parsed.mappings).toBe('string'); + }); + + test('omits per-section sourcesContent when excludeSource is set', async () => { + const parsed = JSON.parse( + await sourceMapStringImpl([vlqModule], { + ...options, + excludeSource: true, + allowIndexMap: true, + }), + ); + expect(parsed.sections).toHaveLength(1); + expect(parsed.sections[0].map.mappings).toBe('AAAA'); + expect(parsed.sections[0].map.sourcesContent).toBeUndefined(); + }); + + test('marks ignored modules with per-section x_google_ignoreList', async () => { + const parsed = JSON.parse( + await sourceMapStringImpl([vlqModule], { + ...options, + shouldAddToIgnoreList: (module: Module<>) => true, + allowIndexMap: true, + }), + ); + expect(parsed.sections).toHaveLength(1); + expect(parsed.sections[0].map.x_google_ignoreList).toEqual([0]); + }); + }, +); diff --git a/packages/metro/src/DeltaBundler/Serializers/sourceMapGenerator.js b/packages/metro/src/DeltaBundler/Serializers/sourceMapGenerator.js index 6352fd41f5..d075660c7d 100644 --- a/packages/metro/src/DeltaBundler/Serializers/sourceMapGenerator.js +++ b/packages/metro/src/DeltaBundler/Serializers/sourceMapGenerator.js @@ -13,13 +13,22 @@ import type {Module} from '../types'; import getSourceMapInfo from './helpers/getSourceMapInfo'; import {isJsModule} from './helpers/js'; -import {fromRawMappings, fromRawMappingsNonBlocking} from 'metro-source-map'; +import { + fromRawMappings, + fromRawMappingsIndexed, + fromRawMappingsNonBlocking, + isVlqMap, +} from 'metro-source-map'; export type SourceMapGeneratorOptions = Readonly<{ excludeSource: boolean, processModuleFilter: (module: Module<>) => boolean, shouldAddToIgnoreList: (module: Module<>) => boolean, getSourceUrl: ?(module: Module<>) => string, + // Allow an index map (sectioned) that passes VLQ-stored maps through + // verbatim, instead of decoding + re-encoding into a flat map. No-op unless + // VLQ maps are actually present. + allowIndexMap?: boolean, }>; function getSourceMapInfosImpl( @@ -78,7 +87,9 @@ function getSourceMapInfosImpl( function sourceMapGenerator( modules: ReadonlyArray>, options: SourceMapGeneratorOptions, -): ReturnType { +): + | ReturnType + | ReturnType { let sourceMapInfos; getSourceMapInfosImpl( true, @@ -93,19 +104,41 @@ function sourceMapGenerator( 'Expected getSourceMapInfosImpl() to finish synchronously.', ); } + if (shouldEmitIndexedMap(options, sourceMapInfos)) { + return fromRawMappingsIndexed(sourceMapInfos); + } return fromRawMappings(sourceMapInfos); } async function sourceMapGeneratorNonBlocking( modules: ReadonlyArray>, options: SourceMapGeneratorOptions, -): ReturnType { +): Promise< + | ReturnType + | ReturnType, +> { const sourceMapInfos = await new Promise< ReadonlyArray>, >(resolve => { getSourceMapInfosImpl(false, resolve, modules, options); }); + if (shouldEmitIndexedMap(options, sourceMapInfos)) { + // The indexed path is a cheap synchronous passthrough — no need to yield. + return fromRawMappingsIndexed(sourceMapInfos); + } return fromRawMappingsNonBlocking(sourceMapInfos); } +// An index map only helps (and only avoids decode) when maps are actually stored +// as VLQ, so gate on both the option and the presence of a VLQ map. +function shouldEmitIndexedMap( + options: SourceMapGeneratorOptions, + sourceMapInfos: ReadonlyArray>, +): boolean { + return ( + options.allowIndexMap === true && + sourceMapInfos.some(info => isVlqMap(info.map)) + ); +} + export {sourceMapGenerator, sourceMapGeneratorNonBlocking}; diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index cedba41833..cece20a2cf 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -296,6 +296,7 @@ export default class Server { shouldAddToIgnoreList: bundleOptions.shouldAddToIgnoreList, getSourceUrl: (module: Module<>) => this._getModuleSourceUrl(module, serializerOptions.sourcePaths), + allowIndexMap: this._config.serializer.unstable_allowIndexMap, }, ); } @@ -1309,6 +1310,7 @@ export default class Server { this._shouldAddModuleToIgnoreList(module), getSourceUrl: (module: Module<>) => this._getModuleSourceUrl(module, serializerOptions.sourcePaths), + allowIndexMap: this._config.serializer.unstable_allowIndexMap, }, ); }, diff --git a/packages/metro/types/DeltaBundler/Serializers/sourceMapGenerator.d.ts b/packages/metro/types/DeltaBundler/Serializers/sourceMapGenerator.d.ts index efd22ce36c..76f696874f 100644 --- a/packages/metro/types/DeltaBundler/Serializers/sourceMapGenerator.d.ts +++ b/packages/metro/types/DeltaBundler/Serializers/sourceMapGenerator.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<49bc83c20821024a7b77f5d5c3168d62>> + * @generated SignedSource<<35bb62d836afac725c73389319206389>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/DeltaBundler/Serializers/sourceMapGenerator.js @@ -17,20 +17,25 @@ import type {Module} from '../types'; -import {fromRawMappings, fromRawMappingsNonBlocking} from 'metro-source-map'; +import {fromRawMappings, fromRawMappingsIndexed} from 'metro-source-map'; export type SourceMapGeneratorOptions = Readonly<{ excludeSource: boolean; processModuleFilter: (module: Module) => boolean; shouldAddToIgnoreList: (module: Module) => boolean; getSourceUrl: null | undefined | ((module: Module) => string); + allowIndexMap?: boolean; }>; declare function sourceMapGenerator( modules: ReadonlyArray, options: SourceMapGeneratorOptions, -): ReturnType; +): + | ReturnType + | ReturnType; declare function sourceMapGeneratorNonBlocking( modules: ReadonlyArray, options: SourceMapGeneratorOptions, -): ReturnType; +): Promise< + ReturnType | ReturnType +>; export {sourceMapGenerator, sourceMapGeneratorNonBlocking}; diff --git a/scripts/profile-memory.sh b/scripts/profile-memory.sh new file mode 100755 index 0000000000..f89d7111c2 --- /dev/null +++ b/scripts/profile-memory.sh @@ -0,0 +1,223 @@ +#!/bin/bash +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +# Metro Memory Profiling Harness +# +# Measures RSS before, during, and after building a bundle. +# +# Usage: +# 1. Start Metro in another terminal: +# NODE_ARGS="--expose-gc" DEV=1 js1 run --prefetch=false +# +# 2. Run this script (default: WildeBundle on port 8081): +# ./profile-memory.sh +# +# Options: +# --port=PORT Metro port (default: 8081) +# --bundle=PATH Bundle path (default: WildeBundle) +# --platform=PLAT Platform (default: ios) +# --app=APP App identifier (default: com.facebook.Wilde) +# --no-delete Skip the DELETE request (keep graph in memory) +# --repeat=N Build N times to measure steady-state (default: 1) + +set -euo pipefail + +PORT=8081 +BUNDLE_PATH="xplat/js/RKJSModules/EntryPoints/WildeBundle.bundle" +PLATFORM="ios" +APP="com.facebook.Wilde" +DO_DELETE=true +REPEAT=1 + +for arg in "$@"; do + case $arg in + --port=*) PORT="${arg#*=}" ;; + --bundle=*) BUNDLE_PATH="${arg#*=}" ;; + --platform=*) PLATFORM="${arg#*=}" ;; + --app=*) APP="${arg#*=}" ;; + --no-delete) DO_DELETE=false ;; + --repeat=*) REPEAT="${arg#*=}" ;; + --help) + sed -n '2,/^$/p' "$0" + exit 0 + ;; + *) echo "Unknown option: $arg"; exit 1 ;; + esac +done + +BUNDLE_URL="http://localhost:$PORT/$BUNDLE_PATH?platform=$PLATFORM&dev=true&app=$APP" +STATUS_URL="http://localhost:$PORT/status" + +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' + +find_metro_pid() { + pgrep -f "[f]b-metro-cli/index.js" 2>/dev/null | head -1 || true +} + +read_rss_mb() { + awk '/^VmRSS:/ {printf "%d", $2/1024}' /proc/"$1"/status 2>/dev/null +} + +read_hwm_mb() { + awk '/^VmHWM:/ {printf "%d", $2/1024}' /proc/"$1"/status 2>/dev/null +} + +print_proc_memory() { + local pid=$1 + echo " VmRSS (current resident): $(awk '/^VmRSS:/ {printf "%d MB", $2/1024}' /proc/"$pid"/status)" + echo " VmHWM (peak resident): $(awk '/^VmHWM:/ {printf "%d MB", $2/1024}' /proc/"$pid"/status)" + echo " VmSize (virtual): $(awk '/^VmSize:/ {printf "%d MB", $2/1024}' /proc/"$pid"/status)" + echo " VmData (heap+data): $(awk '/^VmData:/ {printf "%d MB", $2/1024}' /proc/"$pid"/status)" +} + +echo -e "${BOLD}Metro Memory Profiler${NC}" +echo "" + +# Find Metro +METRO_PID=$(find_metro_pid) +if [ -z "$METRO_PID" ]; then + echo "Metro is not running. Start it first:" + echo "" + echo ' NODE_ARGS="--expose-gc" DEV=1 js1 run --prefetch=false' + echo "" + echo "For V8 heap inspection via Chrome DevTools, add --inspect:" + echo "" + echo ' NODE_ARGS="--expose-gc --inspect=9230" DEV=1 js1 run --prefetch=false' + echo " Then open chrome://inspect and connect to the Metro process." + exit 1 +fi +echo "Metro PID: $METRO_PID" + +# Wait for ready +echo -n "Waiting for Metro... " +READY=false +for _ in $(seq 1 120); do + if curl -s --connect-timeout 2 "$STATUS_URL" 2>/dev/null | grep -q "packager-status:running"; then + READY=true + echo "ready" + break + fi + sleep 1 +done +if [ "$READY" = false ]; then + echo "timed out after 120s" + exit 1 +fi + +# Baseline +echo "" +echo -e "${BOLD}Baseline (startup complete, no bundles loaded)${NC}" +BASELINE_RSS=$(read_rss_mb "$METRO_PID") +print_proc_memory "$METRO_PID" + +# Start background sampler +SAMPLE_FILE=$(mktemp /tmp/metro-mem-XXXXXX.csv) +echo "epoch_s,rss_mb" > "$SAMPLE_FILE" +( + while kill -0 "$METRO_PID" 2>/dev/null; do + rss=$(read_rss_mb "$METRO_PID") + [ -n "$rss" ] && echo "$(date +%s),$rss" >> "$SAMPLE_FILE" + sleep 1 + done +) & +SAMPLER_PID=$! +trap 'kill "$SAMPLER_PID" 2>/dev/null; wait "$SAMPLER_PID" 2>/dev/null || true' EXIT + +for iteration in $(seq 1 "$REPEAT"); do + if [ "$REPEAT" -gt 1 ]; then + echo "" + echo -e "${BOLD}=== Iteration $iteration / $REPEAT ===${NC}" + fi + + # Build + echo "" + echo -e "${BOLD}Building bundle${NC}" + echo -e "${DIM} $BUNDLE_URL${NC}" + BUILD_START=$(date +%s) + HTTP_OUT=$(curl -sS -o /dev/null -w "%{http_code}\t%{time_total}\t%{size_download}" "$BUNDLE_URL" 2>&1) + BUILD_END=$(date +%s) + + HTTP_CODE=$(echo "$HTTP_OUT" | cut -f1) + HTTP_TIME=$(echo "$HTTP_OUT" | cut -f2) + HTTP_SIZE=$(echo "$HTTP_OUT" | cut -f3) + + echo " HTTP $HTTP_CODE in ${HTTP_TIME}s, $(echo "$HTTP_SIZE" | awk '{printf "%.1f MB", $1/1048576}') (wall: $((BUILD_END - BUILD_START))s)" + + if [ "$HTTP_CODE" != "200" ]; then + echo " Bundle build failed (HTTP $HTTP_CODE). Check Metro logs." + echo " Try the URL in a browser to see the error:" + echo " $BUNDLE_URL" + kill "$SAMPLER_PID" 2>/dev/null + exit 1 + fi + + sleep 2 + echo "" + echo -e "${BOLD}Post-build${NC}" + POSTBUILD_RSS=$(read_rss_mb "$METRO_PID") + print_proc_memory "$METRO_PID" + + # Delete graph + if [ "$DO_DELETE" = true ]; then + echo "" + echo -e "${BOLD}After DELETE (graph freed)${NC}" + curl -sS -X DELETE "$BUNDLE_URL" > /dev/null 2>&1 + sleep 2 + POSTDELETE_RSS=$(read_rss_mb "$METRO_PID") + print_proc_memory "$METRO_PID" + else + POSTDELETE_RSS=$POSTBUILD_RSS + fi +done + +# Stop sampler +kill "$SAMPLER_PID" 2>/dev/null || true +wait "$SAMPLER_PID" 2>/dev/null || true +trap - EXIT + +# Peak from samples (HWM from /proc is more reliable than 1s polling) +PEAK_RSS=$(read_hwm_mb "$METRO_PID") +[ -z "$PEAK_RSS" ] && PEAK_RSS=$POSTBUILD_RSS + +# Summary +echo "" +echo -e "${BOLD}Summary${NC}" +echo "-------" +printf " %-30s %6s MB\n" "Baseline RSS:" "$BASELINE_RSS" +printf " %-30s %6s MB\n" "Peak RSS (sampled @1s):" "$PEAK_RSS" +printf " %-30s %6s MB\n" "Post-build RSS:" "$POSTBUILD_RSS" +if [ "$DO_DELETE" = true ]; then + printf " %-30s %6s MB\n" "Post-delete RSS:" "$POSTDELETE_RSS" +fi +echo "" +printf " %-30s %+6d MB\n" "Growth (build):" "$((POSTBUILD_RSS - BASELINE_RSS))" +if [ "$DO_DELETE" = true ]; then + printf " %-30s %+6d MB\n" "Retained after delete:" "$((POSTDELETE_RSS - BASELINE_RSS))" +fi +echo "" + +# Save report +REPORT="/tmp/metro-memory-$(date +%Y%m%d-%H%M%S).txt" +{ + echo "Metro Memory Profile — $(date)" + echo "Bundle: $BUNDLE_PATH ($PLATFORM, app=$APP)" + echo "PID: $METRO_PID" + echo "" + echo "Baseline RSS: ${BASELINE_RSS} MB" + echo "Peak RSS: ${PEAK_RSS} MB" + echo "Post-build RSS: ${POSTBUILD_RSS} MB" + echo "Post-delete RSS: ${POSTDELETE_RSS} MB" + echo "Build growth: $((POSTBUILD_RSS - BASELINE_RSS)) MB" + echo "Retained: $((POSTDELETE_RSS - BASELINE_RSS)) MB" + echo "" + echo "Samples (${SAMPLE_FILE}):" + cat "$SAMPLE_FILE" 2>/dev/null || echo "(no samples)" +} > "$REPORT" + +echo "Report: $REPORT" +echo "Samples: $SAMPLE_FILE"