diff --git a/.circleci/config.yml b/.circleci/config.yml index 6a98ee7dcc55..8b0129440d59 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -123,12 +123,7 @@ jobs: - run: name: Add examples/* to yarn workspace command: | - npm pkg delete workspaces[6] - npm pkg delete workspaces[5] - npm pkg delete workspaces[4] - npm pkg delete workspaces[3] - npm pkg delete workspaces[2] - npm pkg delete workspaces[1] + npm pkg set workspaces="$(node -p "JSON.stringify(require('./package.json').workspaces.filter(w => !w.startsWith('examples/') && w !== 'website'))")" --json npm pkg set 'workspaces[]'='examples/todo-app' npm pkg set 'workspaces[]'='examples/github-app' npm pkg set 'workspaces[]'='examples/normalizr-relationships' diff --git a/.cursor/rules/ci-config.mdc b/.cursor/rules/ci-config.mdc index 84a9c7171d3c..363764c2caac 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -19,7 +19,7 @@ alwaysApply: false - `esmodule-types` also runs `examples/todo-app/tsconfig.typetest-libcheck.json` (`skipLibCheck: false`, `types: []`) so errors inside the legacy outputs fail CI, and `esmodule-types-latest` runs it with `--moduleResolution bundler` (TS 7 removed `node`) to cover `lib/`; the other typetests use `skipLibCheck: true`. - Any change to legacy types building must leave `ts*/` output byte-identical to master (diff it) unless it intentionally changes published types (then add a changeset). - Never `git fetch --depth` the base branch in the relevance check: a shallow fetch severs the merge base and the three-dot diff fails. -- Changing root `package.json` `workspaces` requires updating the `setup` job's workspace trimming step. +- The `setup` job trims root `workspaces` by name (drops `examples/*` and `website`, keeps the rest). A new workspace outside those is kept in every CircleCI job; update the filter if it should not be. ## GitHub Actions (`.github/workflows/`) diff --git a/.github/workflows/benchmark-react.yml b/.github/workflows/benchmark-react.yml index 4c9638572d62..7b15e428a4aa 100644 --- a/.github/workflows/benchmark-react.yml +++ b/.github/workflows/benchmark-react.yml @@ -10,6 +10,7 @@ on: - 'packages/endpoint/src/schemas/**' - 'packages/normalizr/src/**' - 'examples/benchmark-react/**' + - 'examples/gc-shared/**' - '.github/workflows/benchmark-react.yml' push: branches: @@ -20,6 +21,7 @@ on: - 'packages/endpoint/src/schemas/**' - 'packages/normalizr/src/**' - 'examples/benchmark-react/**' + - 'examples/gc-shared/**' - '.github/workflows/benchmark-react.yml' concurrency: diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 19411426e69c..ba3c353bb94f 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -9,6 +9,7 @@ on: - 'packages/endpoint/src/schemas/**' - 'packages/core/src/**' - 'examples/benchmark/**' + - 'examples/gc-shared/**' - '.github/workflows/benchmark.yml' push: branches: @@ -18,6 +19,7 @@ on: - 'packages/endpoint/src/schemas/**' - 'packages/core/src/**' - 'examples/benchmark/**' + - 'examples/gc-shared/**' - '.github/workflows/benchmark.yml' permissions: contents: write diff --git a/AGENTS.md b/AGENTS.md index b73593a1a67b..c42b9011e6a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,12 +22,14 @@ Monorepo for `@data-client` high performance npm packages. **Targeted tests**: `yarn test --selectProjects ReactDOM --testPathPatterns packages/react` (project names: `ReactDOM`, `Node`, `ReactNative`) +**Other React versions** (CI runs `^17`, `^18`, `19.3`): never swap versions in the main checkout. In a throwaway `git worktree`: `yarn install`, `yarn run ci:build:setup`, then the matching `npm pkg delete resolutions.*` + `yarn up ...` lines from the `unit_tests` job in `.circleci/config.yml`, then `yarn test --no-cache ...`. Without `--no-cache`, transforms cached before types were built leave `const enum` members (e.g. `ExpiryStatus.Valid`) undefined; that looks like a version failure but isn't. React 17's legacy Suspense keeps hidden content beside the fallback in `textContent`. + ## CI - **CircleCI** (`.circleci/config.yml`) — PR validation: lint, typecheck, unit tests (React 17/18/native/latest), Node matrix, ESM type checks (TS 4.0–5.3+), browser build. - **GitHub Actions** (`.github/workflows/`) — release (`changesets`), bundle size PR comments, benchmark regression detection. -Changing root `package.json` `workspaces` requires updating `.circleci/config.yml` (`setup` job) and `.github/workflows/` install steps. +Adding a root `package.json` workspace outside `examples/` or `website` requires updating `.circleci/config.yml` (`setup` job trims by name) and `.github/workflows/` install steps. ## Changesets diff --git a/eslint.config.mjs b/eslint.config.mjs index 114716ba1156..d91e67421326 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -62,6 +62,24 @@ export default [ }, }, }, + // Node CommonJS: configs, host scripts, and the GC harness shared modules + { + files: [ + '**/*.cjs', + 'examples/gc-shared/**/*.js', + 'examples/benchmark-native/*.config.js', + 'examples/benchmark-native/.prettierrc.js', + 'examples/benchmark-native/scripts/**', + ], + languageOptions: { + sourceType: 'commonjs', + globals: globals.node, + }, + rules: { + // `const crypto = require('crypto')` shadows Node's global by design + 'no-redeclare': ['error', { builtinGlobals: false }], + }, + }, { files: ['examples/**/*.?(m|c)ts?(x)'], rules: { diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 94cecb86a05e..4405cffeb82d 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -4,7 +4,11 @@ ### Monorepo workspace members -`benchmark`, `benchmark-react`, `test-bundlesize`, `normalizr-*`, `coin-app` — listed in root `package.json` workspaces, managed by yarn. Most use `workspace:*` for `@data-client/*` deps. +`benchmark`, `benchmark-react`, `benchmark-native`, `test-bundlesize`, `normalizr-*`, `coin-app` — listed in root `package.json` workspaces, managed by yarn. Most use `workspace:*` for `@data-client/*` deps. `benchmark-native` (React Native Android) uses the root Jest/ESLint/TypeScript toolchain and is not installed in CI. + +### Shared (non-workspace) code + +`gc-shared` — CommonJS `.js` + hand-written `.d.ts` shared by the GC harnesses in `benchmark`, `benchmark-react`, and `benchmark-native`. No `package.json` on purpose (see its README); include it in each harness's build-provenance inputs. ### Standalone (StackBlitz demos) @@ -22,4 +26,4 @@ ## Build Tooling -Most examples use Webpack via `@anansi/webpack-config`. Exceptions: `nextjs` (Next.js), `vue-todo-app` (Vite), `normalizr-*` (Node scripts via `babel-node`). +Most examples use Webpack via `@anansi/webpack-config`. Exceptions: `nextjs` (Next.js), `vue-todo-app` (Vite), `normalizr-*` (Node scripts via `babel-node`), `benchmark-native` (Metro). diff --git a/examples/benchmark-native/.gitignore b/examples/benchmark-native/.gitignore new file mode 100644 index 000000000000..70fbab4ddb4b --- /dev/null +++ b/examples/benchmark-native/.gitignore @@ -0,0 +1,85 @@ +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +**/.xcode.env.local + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ +*.keystore +!debug.keystore +.kotlin/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/ + +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/screenshots +**/fastlane/test_output + +# Bundle artifact +*.jsbundle + +# Ruby / CocoaPods +**/Pods/ +/vendor/bundle/ + +# Temporary files created by Metro to check the health of the file watcher +.metro-health-check* + +# testing +/coverage + +# Local measurement artifacts +/artifacts/ +*.hprof + +# Generated BuildManifest asset (prepare step); scripts remain tracked +android/app/src/main/assets/build-manifest.json + +# Ephemeral local JDK used only for CI-less verification (never commit) +/.jdk/ + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions diff --git a/examples/benchmark-native/.prettierrc.js b/examples/benchmark-native/.prettierrc.js new file mode 100644 index 000000000000..06860c8d1b12 --- /dev/null +++ b/examples/benchmark-native/.prettierrc.js @@ -0,0 +1,5 @@ +module.exports = { + arrowParens: 'avoid', + singleQuote: true, + trailingComma: 'all', +}; diff --git a/examples/benchmark-native/.watchmanconfig b/examples/benchmark-native/.watchmanconfig new file mode 100644 index 000000000000..0967ef424bce --- /dev/null +++ b/examples/benchmark-native/.watchmanconfig @@ -0,0 +1 @@ +{} diff --git a/examples/benchmark-native/App.tsx b/examples/benchmark-native/App.tsx new file mode 100644 index 000000000000..e4ac5f7e29ea --- /dev/null +++ b/examples/benchmark-native/App.tsx @@ -0,0 +1,199 @@ +/** + * Minimal Run/status UI + auto-run from Android intent extras. + * Prevents overlapping runs. Sustained visual update during measurement. + */ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Pressable, StyleSheet, Text, View, StatusBar } from 'react-native'; + +import BenchNative from './src/BenchNative'; +import { setAnimationTickListener } from './src/measure'; +import { executeMeasurement } from './src/runOrchestration'; +import { scenarioId } from './src/scenario'; +import type { GCScenarioConfig, LaunchConfig } from './src/types'; +import { parseLaunchConfig } from './src/validateConfig'; + +const DEFAULT_CONFIG: GCScenarioConfig = { + candidateKind: 'entity', + pattern: 'unique', + count: 1000, + control: 'gc', +}; + +export default function App() { + const [status, setStatus] = useState('Ready'); + const [lastId, setLastId] = useState(null); + const [animPhase, setAnimPhase] = useState(0); + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [samples, setSamples] = useState(1); + const [label, setLabel] = useState(undefined); + const runningRef = useRef(false); + const autoStarted = useRef(false); + + const runWithConfig = useCallback( + async (cfg: GCScenarioConfig, sampleCount: number, runLabel?: string) => { + if (runningRef.current) { + setStatus('Busy — overlapping runs blocked'); + return; + } + runningRef.current = true; + setLastId(scenarioId(cfg)); + try { + await executeMeasurement({ + config: cfg, + samples: sampleCount, + label: runLabel, + onStatus: setStatus, + }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + setStatus(`Error: ${message}`); + } finally { + runningRef.current = false; + } + }, + [], + ); + + const runOnce = useCallback(() => { + void runWithConfig(config, samples, label); + }, [config, label, runWithConfig, samples]); + + useEffect(() => { + setAnimationTickListener(t => { + setAnimPhase(t); + }); + return () => setAnimationTickListener(null); + }, []); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const raw = await BenchNative.getLaunchConfig(); + if (cancelled) return; + let parsed: LaunchConfig; + try { + parsed = parseLaunchConfig(raw); + } catch (e) { + setStatus( + `Invalid launch config: ${e instanceof Error ? e.message : String(e)}`, + ); + return; + } + const cfg: GCScenarioConfig = { + candidateKind: parsed.candidateKind, + pattern: parsed.pattern, + count: parsed.count, + control: parsed.control, + }; + setConfig(cfg); + setSamples(parsed.samples); + setLabel(parsed.label); + if (parsed.autoRun && !autoStarted.current) { + autoStarted.current = true; + setTimeout(() => { + if (cancelled) return; + void runWithConfig(cfg, parsed.samples, parsed.label); + }, 50); + } + } catch (e) { + if (!cancelled) { + setStatus( + `Launch config unavailable: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + })(); + return () => { + cancelled = true; + }; + }, [runWithConfig]); + + const pulse = ((animPhase / 16) % 40) / 40; + + return ( + + + GC Bench (Hermes release) + + {config.candidateKind}/{config.pattern}/{config.count}/{config.control} + + {lastId ? {lastId} : null} + {status} + + + + [ + styles.button, + pressed && styles.buttonPressed, + ]} + onPress={runOnce} + accessibilityRole="button" + accessibilityLabel="Run GC benchmark" + > + Run + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + padding: 24, + justifyContent: 'center', + backgroundColor: '#f2f4f7', + }, + title: { + fontSize: 22, + fontWeight: '600', + marginBottom: 8, + color: '#111', + }, + meta: { + fontSize: 14, + color: '#444', + marginBottom: 4, + }, + id: { + fontSize: 12, + color: '#666', + marginBottom: 12, + fontFamily: 'monospace', + }, + status: { + fontSize: 14, + color: '#222', + marginBottom: 24, + }, + anim: { + width: 48, + height: 48, + backgroundColor: '#2a6f97', + marginBottom: 32, + alignSelf: 'center', + }, + button: { + backgroundColor: '#1b4332', + paddingVertical: 14, + paddingHorizontal: 28, + alignSelf: 'flex-start', + }, + buttonPressed: { + opacity: 0.85, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/examples/benchmark-native/README.md b/examples/benchmark-native/README.md new file mode 100644 index 000000000000..bb1a05ee21fd --- /dev/null +++ b/examples/benchmark-native/README.md @@ -0,0 +1,107 @@ +# Release-Hermes Android GC benchmark + +Private monorepo workspace `example-benchmark-native`: Android-only React Native **0.86.2** app (React **19.2.3**, Hermes enabled). Measures `@data-client/core` cache GC interaction cost on a physical device in a **release** build. + +This phase is **manual**, not CI. Emulators are functional checks only — not authoritative. + +**APK shape:** only a **single** universal release APK (`app-release.apk`) is supported. Split / multi-APK installs are rejected by the collect script. + +## Prerequisites + +- Node `>= 22.11`, Yarn 4 (repo root) +- JDK compatible with the RN 0.86 Android Gradle Plugin +- Android SDK (`ANDROID_HOME`), platform tools (`adb`), and exactly one device (or set `ANDROID_SERIAL`) +- Built `@data-client/core` +- Physical mid-range Android device for authoritative runs + +## Install / build + +Authoritative release path (prepare BuildManifest → Gradle → sidecar): + +```bash +yarn install +yarn workspace @data-client/core run build:lib +yarn workspace example-benchmark-native typecheck +yarn workspace example-benchmark-native lint +yarn workspace example-benchmark-native test +yarn workspace example-benchmark-native build:android:release +``` + +Tooling comes from the monorepo root: Jest 30 (`preset: 'react-native'`), the root ESLint flat config (`lint` runs `yarn g:lint`), and TypeScript via `@typescript/native`. + +This writes: + +- embedded asset `android/app/src/main/assets/build-manifest.json` (generated; gitignored) +- `android/app/build/outputs/apk/release/app-release.apk` +- `artifacts/build-sidecar.json` (APK sha256 + buildId; **collection authority**) + +## Provenance + +- **`buildId`** — source-build identity: deterministic `sha256` over `schemaVersion`, `gitCommit`, `gitDirty`, `sourceDigest` (not APK bytes, not timestamps). +- **`apkSha256`** — artifact identity: hash of the single `app-release.apk` bytes (sidecar + installed APK must match). +- **`sidecarId`** — artifact-aware: `sha256(buildId ∥ apkSha256)`. +- **Sidecar is authority**, not the live checkout. Collection verifies sidecar identity, source digest, local + installed APK sha256, and report embedded `buildId`. +- Intent `label` is optional metadata only. Commit is fingerprinting input to `buildId`, not a separate launch authority. +- Tampered metadata or a forged `buildId` fails `verifyManifestBuildId` / collect. + +## Scenario IDs + +`android/{entity|endpoint|mixed}/{unique|duplicate}/{1000|10000|100000}/interaction/{gc|no-gc}` + +- `duplicate` is entity-only; invalid axes fail validation (host + in-app) + +## Host collection + +The app writes `gc-report.json` to **app-specific external storage** (`Android/data//files/`, `Context.getExternalFilesDir`) so a non-debuggable release APK does not depend on `run-as` (that only works when `android:debuggable` is true). On Android 10+ user builds the shell often cannot read `/Android/data`, so the app also mirrors `dataclient-gc-report.json` to public Downloads. `scripts/collect-report.sh` deletes both copies (including the MediaStore Downloads row) and refuses to start if a leftover remains, then `adb pull`s those paths (Downloads first, then `externalFilesDir`, then the `REPORT_READY` logcat path). Do not set `release { debuggable true }` — this APK is already debug-signed, and that would soften release-Hermes measurement semantics. + +```bash +# exactly one device, or: +# ANDROID_SERIAL=… CANDIDATE_KIND=entity … +CANDIDATE_KIND=entity PATTERN=unique COUNT=1000 CONTROL=gc SAMPLES=5 \ + yarn workspace example-benchmark-native collect +``` + +Authoritative **100k**: + +```bash +CANDIDATE_KIND=entity PATTERN=unique COUNT=100000 CONTROL=gc SAMPLES=5 \ + yarn workspace example-benchmark-native collect +CANDIDATE_KIND=entity PATTERN=unique COUNT=100000 CONTROL=no-gc SAMPLES=5 \ + yarn workspace example-benchmark-native collect +``` + +### Matrix + +Full canonical matrix from the shared GC protocol ([`examples/gc-shared`](../gc-shared/README.md); `node ../gc-shared/protocol-cli.js list android [filter]` prints it); 100k skipped unless `FULL=1` or a filter selects those rows. Filters match slash-bounded contiguous segments of the stable id `android/{kind}/{pattern}/{count}/interaction/{control}` (or `^prefix`), the same syntax as the Node and browser GC harnesses. So `entity/unique/1000` does not select 10k or 100k, and `/100000/` selects only the 100k rows without `FULL=1`. Because `interaction` sits between count and control, select a control with e.g. `unique/1000/interaction/gc` or `interaction/no-gc` (the old `kind/pattern/count/control` form no longer matches). Host axis validation (`collect`, `matrix`) also goes through `protocol-cli.js validate`. + +```bash +SAMPLES=3 yarn workspace example-benchmark-native matrix +SAMPLES=5 yarn workspace example-benchmark-native matrix entity/unique/100000 +SAMPLES=5 yarn workspace example-benchmark-native matrix interaction/no-gc +FULL=1 SAMPLES=5 yarn workspace example-benchmark-native matrix +``` + +## UI frame semantics + +| Source | Quantity | Missed-frame math | +| --- | --- | --- | +| FrameMetrics | `TOTAL_DURATION` (duration) | `max(0, ceil(duration/period) − 1)` | +| Choreographer | frame-time delta (interval) | `max(0, round(interval/period) − 1)` | +| JS rAF | timestamp intervals | same as Choreographer | + +Every sample records `uiCaptureSource`, frame count, max/total duration aggregates, missed frames, and refresh period/rate. Zero frames or invalid refresh period fail the run. Capture stop is idempotent; JS tears down in `finally`. + +## Memory caveat + +Without forced Hermes GC, JS heap and process PSS/RSS are observational/noisy — not a memory gate alone. Compare repeated `gc` vs `no-gc`. No `System.gc()`. + +## Verification without a device + +```bash +bash -n scripts/*.sh +DRY_RUN=1 bash scripts/run-matrix.sh /100000/ +yarn workspace example-benchmark-native test +yarn workspace example-benchmark-native typecheck +``` + +On-device install/start/pull remains unverified until a device is attached. diff --git a/examples/benchmark-native/__tests__/accept-collected-report.test.ts b/examples/benchmark-native/__tests__/accept-collected-report.test.ts new file mode 100644 index 000000000000..3a801e45c80f --- /dev/null +++ b/examples/benchmark-native/__tests__/accept-collected-report.test.ts @@ -0,0 +1,108 @@ +/** + * Pulled-report acceptance: buildId plus scenario axes and sample count. + */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + assertCollectedReport, +} = require('../scripts/accept-collected-report.cjs'); + +export {}; + +function report(overrides: Record = {}) { + return { + build: { buildId: 'abc' }, + scenarios: [ + { + candidateKind: 'entity', + pattern: 'unique', + count: 1000, + control: 'gc', + samples: [{ schemaVersion: 1 }, { schemaVersion: 1 }], + ...overrides, + }, + ], + }; +} + +const expected = { + buildId: 'abc', + candidateKind: 'entity', + pattern: 'unique', + count: 1000, + control: 'gc', + samples: 2, +}; + +describe('assertCollectedReport', () => { + it('accepts a report that matches the request', () => { + expect(() => assertCollectedReport(report(), expected)).not.toThrow(); + }); + + it('rejects a different kind, pattern, count, control, or sample count', () => { + expect(() => + assertCollectedReport(report({ candidateKind: 'endpoint' }), expected), + ).toThrow(/kind endpoint != entity/); + expect(() => + assertCollectedReport(report({ pattern: 'duplicate' }), expected), + ).toThrow(/pattern duplicate != unique/); + expect(() => + assertCollectedReport(report({ count: 100000 }), expected), + ).toThrow(/count 100000 != 1000/); + expect(() => + assertCollectedReport(report({ control: 'no-gc' }), expected), + ).toThrow(/control no-gc != gc/); + const short = report(); + short.scenarios[0].samples = [{ schemaVersion: 1 }]; + expect(() => assertCollectedReport(short, expected)).toThrow( + /samples 1 != 2/, + ); + }); + + it('rejects a buildId mismatch before trusting the axes', () => { + const forged = report(); + forged.build.buildId = 'other'; + expect(() => assertCollectedReport(forged, expected)).toThrow(/buildId/); + }); + + it('does not rewrite the report file when the axes do not match', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-accept-')); + const out = path.join(dir, 'gc-report.json'); + const sidecar = path.join(dir, 'sidecar.json'); + const body = report({ candidateKind: 'mixed' }); + fs.writeFileSync(out, JSON.stringify(body)); + fs.writeFileSync( + sidecar, + JSON.stringify({ + buildId: 'abc', + sourceDigest: 'd', + apkSha256: 'a', + apkPath: '/apk', + sidecarId: 's', + apkSizeBytes: 1, + }), + ); + const before = fs.readFileSync(out); + const { spawnSync } = require('child_process'); + const result = spawnSync( + 'node', + [ + path.join('scripts', 'accept-collected-report.cjs'), + out, + sidecar, + 'installed', + 'entity', + 'unique', + '1000', + 'gc', + '2', + ], + { encoding: 'utf8' }, + ); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/does not match request/); + expect(fs.readFileSync(out).equals(before)).toBe(true); + }); +}); diff --git a/examples/benchmark-native/__tests__/build-identity.test.ts b/examples/benchmark-native/__tests__/build-identity.test.ts new file mode 100644 index 000000000000..221196859428 --- /dev/null +++ b/examples/benchmark-native/__tests__/build-identity.test.ts @@ -0,0 +1,127 @@ +/** + * Deterministic buildId / sidecarId identity (source vs artifact). + */ +const { + computeBuildId, + computeSidecarId, + verifyManifestBuildId, + verifySidecarIdentity, +} = require('../scripts/build-identity.cjs'); + +const DIGEST_A = 'a'.repeat(64); +const DIGEST_B = 'b'.repeat(64); +const APK_A = 'c'.repeat(64); +const APK_B = 'd'.repeat(64); + +describe('computeBuildId', () => { + it('is deterministic over schemaVersion, gitCommit, gitDirty, sourceDigest', () => { + const a = computeBuildId({ + schemaVersion: 1, + gitCommit: 'abc', + gitDirty: false, + sourceDigest: DIGEST_A, + }); + const b = computeBuildId({ + schemaVersion: 1, + gitCommit: 'abc', + gitDirty: false, + sourceDigest: DIGEST_A, + }); + expect(a).toBe(b); + expect(a).toMatch(/^[0-9a-f]{64}$/); + }); + + it('changes when any canonical input changes', () => { + const base = { + schemaVersion: 1 as const, + gitCommit: 'abc', + gitDirty: false, + sourceDigest: DIGEST_A, + }; + const id = computeBuildId(base); + expect(computeBuildId({ ...base, gitCommit: 'xyz' })).not.toBe(id); + expect(computeBuildId({ ...base, gitDirty: true })).not.toBe(id); + expect(computeBuildId({ ...base, sourceDigest: DIGEST_B })).not.toBe(id); + }); +}); + +describe('verifyManifestBuildId', () => { + it('accepts a consistent manifest', () => { + const inputs = { + schemaVersion: 1, + gitCommit: 'deadbeef', + gitDirty: true, + sourceDigest: DIGEST_A, + }; + const buildId = computeBuildId(inputs); + expect(verifyManifestBuildId({ ...inputs, buildId })).toBe(buildId); + }); + + it('rejects forged buildId', () => { + expect(() => + verifyManifestBuildId({ + schemaVersion: 1, + buildId: '0'.repeat(64), + gitCommit: 'deadbeef', + gitDirty: false, + sourceDigest: DIGEST_A, + }), + ).toThrow(/buildId mismatch/); + }); + + it('rejects tampered metadata that no longer matches buildId', () => { + const inputs = { + schemaVersion: 1, + gitCommit: 'deadbeef', + gitDirty: false, + sourceDigest: DIGEST_A, + }; + const buildId = computeBuildId(inputs); + expect(() => + verifyManifestBuildId({ + ...inputs, + buildId, + sourceDigest: DIGEST_B, + }), + ).toThrow(/buildId mismatch/); + }); +}); + +describe('computeSidecarId / verifySidecarIdentity', () => { + it('binds source buildId to artifact apkSha256', () => { + const buildId = computeBuildId({ + schemaVersion: 1, + gitCommit: 'abc', + gitDirty: false, + sourceDigest: DIGEST_A, + }); + const id1 = computeSidecarId({ buildId, apkSha256: APK_A }); + const id2 = computeSidecarId({ buildId, apkSha256: APK_B }); + expect(id1).not.toBe(id2); + // Same APK + same source → same sidecarId + expect(computeSidecarId({ buildId, apkSha256: APK_A })).toBe(id1); + }); + + it('rejects tampered sidecarId while preserving buildId checks', () => { + const inputs = { + schemaVersion: 1, + gitCommit: 'abc', + gitDirty: false, + sourceDigest: DIGEST_A, + }; + const buildId = computeBuildId(inputs); + const apkSha256 = APK_A; + const sidecarId = computeSidecarId({ buildId, apkSha256 }); + expect( + verifySidecarIdentity({ ...inputs, buildId, apkSha256, sidecarId }), + ).toBe(sidecarId); + expect(() => + verifySidecarIdentity({ + ...inputs, + buildId, + apkSha256, + sidecarId: '0'.repeat(64), + }), + ).toThrow(/sidecarId mismatch/); + }); +}); diff --git a/examples/benchmark-native/__tests__/collect-report-script.test.ts b/examples/benchmark-native/__tests__/collect-report-script.test.ts new file mode 100644 index 000000000000..39e1d94bff04 --- /dev/null +++ b/examples/benchmark-native/__tests__/collect-report-script.test.ts @@ -0,0 +1,61 @@ +/** + * Host collect must pull an adb-visible path. Release is not debuggable, so + * run-as cannot read private filesDir on typical physical devices. + */ +const fs = require('fs'); + +const script = fs.readFileSync('scripts/collect-report.sh', 'utf8'); +const executable = script + .split('\n') + .filter((line: string) => !/^\s*#/.test(line)) + .join('\n'); + +describe('collect-report.sh report I/O', () => { + it('does not use run-as to read or wait on the report', () => { + expect(executable).not.toMatch(/run-as/); + }); + + it('pulls app-specific external storage and the Downloads mirror', () => { + expect(script).toMatch(/Android\/data\/\$\{APP_ID\}\/files/); + expect(script).toMatch(/Download\/\$\{PUBLIC_REPORT_NAME\}/); + expect(script).toMatch(/pull_device_report/); + expect(script).toMatch(/pull "\$\{src\}"/); + }); + + it('fails clearly if wait passes but pull cannot read the file', () => { + expect(script).toMatch(/REPORT_READY seen but adb could not pull/); + }); + + it('refuses to collect if a leftover report survives clear', () => { + expect(script).toMatch(/assert_device_reports_absent/); + expect(script).toMatch(/leftover report still on device after clear/); + expect(executable).toMatch( + /content delete --uri content:\/\/media\/external\/downloads/, + ); + }); + + it('force-stops successfully before clearing reports, then starts', () => { + const forceStop = executable.lastIndexOf('am force-stop'); + const clearCall = executable.lastIndexOf('clear_device_reports'); + const absentCall = executable.lastIndexOf('assert_device_reports_absent'); + const start = executable.lastIndexOf('am start'); + expect(forceStop).toBeGreaterThan(-1); + expect(clearCall).toBeGreaterThan(forceStop); + expect(absentCall).toBeGreaterThan(clearCall); + expect(start).toBeGreaterThan(absentCall); + const forceStopLine = executable + .split('\n') + .find((line: string) => line.includes('am force-stop')); + expect(forceStopLine).toBeDefined(); + expect(forceStopLine).not.toMatch(/\|\|\s*true/); + }); + + it('checks the pulled report axes and sample count', () => { + expect(executable).toMatch(/accept-collected-report\.cjs/); + expect(executable).toMatch(/"\$\{CANDIDATE_KIND\}"/); + expect(executable).toMatch(/"\$\{PATTERN\}"/); + expect(executable).toMatch(/"\$\{COUNT\}"/); + expect(executable).toMatch(/"\$\{CONTROL\}"/); + expect(executable).toMatch(/"\$\{SAMPLES\}"/); + }); +}); diff --git a/examples/benchmark-native/__tests__/frame-semantics.test.ts b/examples/benchmark-native/__tests__/frame-semantics.test.ts new file mode 100644 index 000000000000..b44acf25db27 --- /dev/null +++ b/examples/benchmark-native/__tests__/frame-semantics.test.ts @@ -0,0 +1,128 @@ +import { + missedFramesFromDurationMs, + missedFramesFromIntervalMs, + validateUiFrameCapture, + withFrameMetricsLoss, +} from '../src/frames'; + +const P = 16.67; + +describe('missedFramesFromDurationMs (FrameMetrics)', () => { + it('boundary table at .99x 1x 1.01x 2x 2.01x', () => { + expect(missedFramesFromDurationMs(0.99 * P, P)).toBe(0); + expect(missedFramesFromDurationMs(1.0 * P, P)).toBe(0); + // just over one period → ceil → 2 periods → 1 miss + expect(missedFramesFromDurationMs(1.01 * P, P)).toBe(1); + expect(missedFramesFromDurationMs(2.0 * P, P)).toBe(1); + expect(missedFramesFromDurationMs(2.01 * P, P)).toBe(2); + }); +}); + +describe('missedFramesFromIntervalMs (Choreographer / JS rAF)', () => { + it('boundary table at .99x 1x 1.01x 2x 2.01x', () => { + expect(missedFramesFromIntervalMs(0.99 * P, P)).toBe(0); + expect(missedFramesFromIntervalMs(1.0 * P, P)).toBe(0); + // 1.01× still rounds to 1 period → 0 miss + expect(missedFramesFromIntervalMs(1.01 * P, P)).toBe(0); + expect(missedFramesFromIntervalMs(2.0 * P, P)).toBe(1); + // 2.01× rounds to 2 → 1 miss (nearest-period) + expect(missedFramesFromIntervalMs(2.01 * P, P)).toBe(1); + }); +}); + +describe('validateUiFrameCapture', () => { + const ok = { + source: 'FrameMetrics' as const, + frameCount: 3, + maxFrameDurationMs: 20, + totalFrameDurationMs: 50, + missedFrames: 0, + droppedFrameMetrics: 0, + frameMetricsDropped: false, + refreshPeriodMs: P, + refreshRateHz: 60, + }; + + it('accepts a valid aggregate', () => { + expect(() => validateUiFrameCapture(ok)).not.toThrow(); + }); + + it('rejects invalid refresh period and zero frames', () => { + expect(() => validateUiFrameCapture({ ...ok, refreshPeriodMs: 0 })).toThrow( + /refreshPeriodMs/, + ); + expect(() => validateUiFrameCapture({ ...ok, frameCount: 0 })).toThrow( + /insufficient ui frames/, + ); + }); +}); + +describe('withFrameMetricsLoss', () => { + const base = { missedFrames: 2, uiMissedFrames: 1 }; + + it('flags a non-zero drop count without adding it to missed frames', () => { + expect(withFrameMetricsLoss(base, 4)).toEqual({ + missedFrames: 2, + uiMissedFrames: 1, + uiDroppedFrameMetrics: 4, + uiFrameMetricsDropped: true, + }); + }); + + it('leaves a clean capture unflagged', () => { + expect(withFrameMetricsLoss(base, 0)).toEqual({ + missedFrames: 2, + uiMissedFrames: 1, + uiDroppedFrameMetrics: 0, + uiFrameMetricsDropped: false, + }); + }); + + it('rejects a negative drop count', () => { + expect(() => withFrameMetricsLoss(base, -1)).toThrow(/drop count/); + }); +}); + +describe('BenchNativeModule drop accounting', () => { + const kt = require('fs').readFileSync( + 'android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt', + 'utf8', + ) as string; + + function sliceBetween(start: string, end: string): string { + const from = kt.indexOf(start); + const to = kt.indexOf(end, from + start.length); + expect(from).toBeGreaterThan(-1); + expect(to).toBeGreaterThan(from); + return kt.slice(from, to); + } + + it('sums dropCountSinceLastInvocation and does not add it to missed frames', () => { + expect(kt).toMatch( + /OnFrameMetricsAvailableListener \{ _, frameMetrics, dropCountSinceLastInvocation ->/, + ); + expect(kt).toMatch( + /recordFrameMetricsDrop\(dropCountSinceLastInvocation\)/, + ); + const dropFn = sliceBetween( + 'private fun recordFrameMetricsDrop', + 'private fun recordFrameMetricsDuration', + ); + expect(dropFn).toMatch(/droppedFrameMetrics \+= dropCount/); + expect(dropFn).not.toMatch(/missedFrames/); + const durationFn = sliceBetween( + 'private fun recordFrameMetricsDuration', + 'private fun recordChoreographerInterval', + ); + expect(durationFn).not.toMatch(/drop/); + const intervalFn = sliceBetween( + 'private fun recordChoreographerInterval', + 'private fun resetCaptureCounters', + ); + expect(intervalFn).not.toMatch(/drop/); + expect(kt).toMatch(/putInt\("droppedFrameMetrics", droppedFrameMetrics\)/); + expect(kt).toMatch( + /putBoolean\("frameMetricsDropped", droppedFrameMetrics > 0\)/, + ); + }); +}); diff --git a/examples/benchmark-native/__tests__/gcHarness.test.ts b/examples/benchmark-native/__tests__/gcHarness.test.ts new file mode 100644 index 000000000000..79043478b4b4 --- /dev/null +++ b/examples/benchmark-native/__tests__/gcHarness.test.ts @@ -0,0 +1,91 @@ +/** + * Real harness tests against createHarness / queue / reducer (not reimplemented math). + */ +import { + countRemaining, + createHarness, + validateMeasurement, + GC, +} from '../src/gcHarness'; +import type { GCScenarioConfig } from '../src/types'; + +const SMALL = 32; + +function runSweepSample(config: GCScenarioConfig) { + const harness = createHarness(config); + const { policy, expected } = harness; + + expect(policy.queueEntries).toBe(expected.queueEntries); + expect(expected.uniqueTargets).toBe( + config.pattern === 'duplicate' ? 1 : config.count, + ); + + if (config.control === 'no-gc') { + validateMeasurement(config, harness, { + actionCount: 0, + deletionCount: 0, + queueEntries: expected.queueEntries, + uniqueTargets: expected.uniqueTargets, + actionTargetCount: 0, + }); + harness.dispose(); + return; + } + + policy.sweep(); + const action = harness.getCapturedAction(); + expect(action).not.toBeNull(); + expect(action!.type).toBe(GC); + expect(action!.entities.length).toBe(expected.expectedEntitiesInAction); + expect(action!.endpoints.length).toBe(expected.expectedEndpointsInAction); + + if (config.pattern === 'duplicate') { + const unique = new Set(action!.entities.map(p => `${p.key}:${p.pk}`)); + expect(unique.size).toBe(1); + } + + const remaining = countRemaining(harness.getState(), config, expected); + expect(remaining.entityRemaining).toBe(0); + expect(remaining.endpointRemaining).toBe(0); + expect(policy.queueEntries).toBe(0); + + const deletionCount = remaining.entityDeleted + remaining.endpointDeleted; + validateMeasurement(config, harness, { + actionCount: 1, + deletionCount, + queueEntries: expected.queueEntries, + uniqueTargets: expected.uniqueTargets, + actionTargetCount: + expected.expectedEntitiesInAction + expected.expectedEndpointsInAction, + }); + + harness.dispose(); +} + +describe('createHarness end-to-end', () => { + const uniqueKinds = ['entity', 'endpoint', 'mixed'] as const; + + for (const candidateKind of uniqueKinds) { + for (const control of ['gc', 'no-gc'] as const) { + it(`${candidateKind}/unique/${SMALL}/${control}`, () => { + runSweepSample({ + candidateKind, + pattern: 'unique', + count: SMALL, + control, + }); + }); + } + } + + for (const control of ['gc', 'no-gc'] as const) { + it(`entity/duplicate/${SMALL}/${control}`, () => { + runSweepSample({ + candidateKind: 'entity', + pattern: 'duplicate', + count: SMALL, + control, + }); + }); + } +}); diff --git a/examples/benchmark-native/__tests__/protocol.test.ts b/examples/benchmark-native/__tests__/protocol.test.ts new file mode 100644 index 000000000000..192dc7f4594b --- /dev/null +++ b/examples/benchmark-native/__tests__/protocol.test.ts @@ -0,0 +1,163 @@ +/** + * Shared GC protocol (examples/gc-shared) — the single implementation behind + * the Node/browser/Android scenario lists, ids, filters, and the host CLI used + * by run-matrix.sh / collect-report.sh. + */ +const { execFileSync, spawnSync } = require('child_process'); +const path = require('path'); + +const { + listScenarios, + matchScenarioId, + parseScenarioId, + scenarioId, +} = require('../../gc-shared/protocol.js'); + +export {}; + +// Jest runs with cwd = this workspace (see run-matrix-filter.test.ts) +const CLI = path.resolve('../gc-shared/protocol-cli.js'); + +const ids = (platform: string, filter?: string): string[] => + listScenarios(platform, { filter }).map((s: { id: string }) => s.id); +const countOf = (id: string) => id.split('/')[3]; + +describe('scenario ids', () => { + it('round-trips parseScenarioId', () => { + for (const id of [ + 'android/mixed/unique/10000/interaction/no-gc', + 'browser/entity/duplicate/1000/end-to-end/gc', + 'node/endpoint/unique/100000/reducer/gc', + ]) { + expect(scenarioId(parseScenarioId(id))).toBe(id); + } + }); + + it('rejects malformed ids, wrong platform modes, and invalid axes', () => { + expect(() => parseScenarioId('android/entity/unique/1000/gc')).toThrow(); + expect(() => + parseScenarioId('node/entity/unique/1000/interaction/gc'), + ).toThrow(/mode/); + expect(() => + parseScenarioId('android/endpoint/duplicate/1000/interaction/gc'), + ).toThrow(/duplicate pattern only supports/); + expect(() => + parseScenarioId('android/entity/unique/999/interaction/gc'), + ).toThrow(/count/); + }); +}); + +describe('listScenarios', () => { + it('lists android in device matrix order (kind → count → control, then duplicates)', () => { + const all = ids('android'); + expect(all).toHaveLength(24); + expect(all.slice(0, 7)).toEqual([ + 'android/entity/unique/1000/interaction/gc', + 'android/entity/unique/1000/interaction/no-gc', + 'android/entity/unique/10000/interaction/gc', + 'android/entity/unique/10000/interaction/no-gc', + 'android/entity/unique/100000/interaction/gc', + 'android/entity/unique/100000/interaction/no-gc', + 'android/endpoint/unique/1000/interaction/gc', + ]); + expect(all.slice(-2)).toEqual([ + 'android/entity/duplicate/100000/interaction/gc', + 'android/entity/duplicate/100000/interaction/no-gc', + ]); + }); + + it('lists node/browser count-first with every mode', () => { + expect(ids('node')).toHaveLength(72); + expect(ids('browser')).toHaveLength(24); + expect(ids('browser').slice(0, 8).map(countOf)).toEqual( + Array(8).fill('1000'), + ); + }); + + it('lists duplicate only for entity', () => { + const all = ids('android'); + expect(all.some(id => id.includes('/duplicate/'))).toBe(true); + expect(all.some(id => id.startsWith('android/endpoint/duplicate'))).toBe( + false, + ); + }); +}); + +describe('matchScenarioId (slash-bounded filter)', () => { + it('matches segments so 1000 does not select 10000 or 100000', () => { + const counts = (filter: string) => ids('android', filter).map(countOf); + + expect(counts('1000')).toEqual(expect.arrayContaining(['1000'])); + expect(counts('1000').every(count => count === '1000')).toBe(true); + expect(counts('entity/unique/1000')).toEqual(['1000', '1000']); + expect(counts('/100000/').length).toBeGreaterThan(0); + expect(counts('/100000/').every(count => count === '100000')).toBe(true); + expect(counts('100000/').every(count => count === '100000')).toBe(true); + expect(counts('/100000').every(count => count === '100000')).toBe(true); + }); + + it('matches against the full stable id, including the mode segment', () => { + const id = 'android/entity/unique/1000/interaction/gc'; + expect(matchScenarioId(id, 'unique/1000/interaction/gc')).toBe(true); + expect(matchScenarioId(id, 'interaction/gc')).toBe(true); + // kind/pattern/count/control is not contiguous in the stable id + expect(matchScenarioId(id, 'entity/unique/1000/gc')).toBe(false); + }); + + it('supports ^prefix against the stable id', () => { + expect(ids('android', '^android/mixed/')).toHaveLength(6); + expect(ids('android', '^entity')).toHaveLength(0); + }); + + it('treats empty filters as match-all', () => { + expect(ids('android', '')).toHaveLength(24); + expect(ids('android', '/')).toHaveLength(24); + }); +}); + +describe('protocol-cli.js', () => { + it('lists ids one per line', () => { + const out = execFileSync('node', [CLI, 'list', 'android', '/10000/'], { + encoding: 'utf8', + }); + expect(out.trim().split('\n')).toEqual(ids('android', '/10000/')); + }); + + it('validates host config with env-var wording', () => { + const run = (...args: string[]) => + spawnSync('node', [CLI, 'validate', ...args], { encoding: 'utf8' }); + + expect(run('entity', 'duplicate', '100000', 'gc', '5').status).toBe(0); + const cases: [string[], string][] = [ + [ + ['widget', 'unique', '1000', 'gc'], + 'error: invalid CANDIDATE_KIND=widget; expected entity|endpoint|mixed', + ], + [ + ['entity', 'twice', '1000', 'gc'], + 'error: invalid PATTERN=twice; expected unique|duplicate', + ], + [ + ['entity', 'unique', '1000', 'off'], + 'error: invalid CONTROL=off; expected gc|no-gc', + ], + [ + ['entity', 'unique', '01000', 'gc'], + 'error: invalid COUNT=01000; expected 1000|10000|100000', + ], + [ + ['endpoint', 'duplicate', '1000', 'gc'], + 'error: duplicate pattern only supports CANDIDATE_KIND=entity (got endpoint)', + ], + [ + ['entity', 'unique', '1000', 'gc', '51'], + 'error: invalid SAMPLES=51; expected integer 1..50', + ], + ]; + for (const [args, message] of cases) { + const res = run(...args); + expect(res.status).toBe(1); + expect(res.stderr.trim()).toBe(message); + } + }); +}); diff --git a/examples/benchmark-native/__tests__/rafCollectSequence.test.ts b/examples/benchmark-native/__tests__/rafCollectSequence.test.ts new file mode 100644 index 000000000000..44ffee9e2547 --- /dev/null +++ b/examples/benchmark-native/__tests__/rafCollectSequence.test.ts @@ -0,0 +1,77 @@ +import { + createManualFrameScheduler, + runRafCollectSequence, +} from '../src/rafCollectSequence'; + +describe('runRafCollectSequence failure handling', () => { + it('rejects promptly when collectWork throws and no-ops queued frames', async () => { + const scheduler = createManualFrameScheduler(16.67); + let terminateCalls = 0; + let collectCalls = 0; + const nowMs = 0; + + const pending = runRafCollectSequence({ + scheduleFrame: scheduler.scheduleFrame, + now: () => nowMs, + scheduleTimeout0: cb => { + // Run macrotask probe synchronously for determinism. + cb(); + }, + postFrames: 2, + collectWork: () => { + collectCalls++; + throw new Error('sweep failed'); + }, + onTerminateError: () => { + terminateCalls++; + }, + }); + + // pre → collect (queues post, then throws) + scheduler.flush(1); // pre + expect(scheduler.pending()).toBe(1); + scheduler.flush(1); // collect — throws after queueing post + + // Promise rejects promptly so outer `finally` can stop animation/native capture. + await expect(pending).rejects.toThrow('sweep failed'); + expect(collectCalls).toBe(1); + expect(terminateCalls).toBe(1); + + // Post frame was queued before throw; flushing it must no-op (no second reject / terminate). + const pendingAfter = scheduler.pending(); + expect(pendingAfter).toBeGreaterThanOrEqual(1); + expect(() => scheduler.flush(5)).not.toThrow(); + expect(terminateCalls).toBe(1); + expect(collectCalls).toBe(1); + }); + + it('resolves with timestamps when collectWork succeeds', async () => { + const scheduler = createManualFrameScheduler(10); + let nowMs = 1000; + + const pending = runRafCollectSequence({ + scheduleFrame: scheduler.scheduleFrame, + now: () => { + nowMs += 1; + return nowMs; + }, + scheduleTimeout0: cb => cb(), + postFrames: 2, + collectWork: () => ({ + totalMs: 12.5, + actionCount: 1, + }), + }); + + // pre, collect, post, post-final → resolve via timeout0 + scheduler.flush(1); + scheduler.flush(1); + scheduler.flush(1); + scheduler.flush(1); + + const result = await pending; + expect(result.totalMs).toBe(12.5); + expect(result.actionCount).toBe(1); + expect(result.frameTimestamps.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/examples/benchmark-native/__tests__/readJsHeap.test.ts b/examples/benchmark-native/__tests__/readJsHeap.test.ts new file mode 100644 index 000000000000..faa630f2679f --- /dev/null +++ b/examples/benchmark-native/__tests__/readJsHeap.test.ts @@ -0,0 +1,43 @@ +import { readJsHeapBytes } from '../src/BenchNative'; + +describe('readJsHeapBytes', () => { + const g = globalThis as typeof globalThis & { + performance?: BenchPerformance; + }; + const original = g.performance; + + afterEach(() => { + Object.defineProperty(g, 'performance', { + value: original, + configurable: true, + writable: true, + }); + }); + + it('returns undefined when performance is absent', () => { + Object.defineProperty(g, 'performance', { + value: undefined, + configurable: true, + writable: true, + }); + expect(readJsHeapBytes()).toBeUndefined(); + }); + + it('returns undefined when memory is absent', () => { + Object.defineProperty(g, 'performance', { + value: { now: () => 0 }, + configurable: true, + writable: true, + }); + expect(readJsHeapBytes()).toBeUndefined(); + }); + + it('returns usedJSHeapSize when exposed', () => { + Object.defineProperty(g, 'performance', { + value: { now: () => 0, memory: { usedJSHeapSize: 12345 } }, + configurable: true, + writable: true, + }); + expect(readJsHeapBytes()).toBe(12345); + }); +}); diff --git a/examples/benchmark-native/__tests__/run-matrix-filter.test.ts b/examples/benchmark-native/__tests__/run-matrix-filter.test.ts new file mode 100644 index 000000000000..2000855d6be1 --- /dev/null +++ b/examples/benchmark-native/__tests__/run-matrix-filter.test.ts @@ -0,0 +1,68 @@ +/** + * Host matrix filter must match slash-bounded segments. A raw substring + * would treat 1000 as part of 10000 and 100000, and a non-empty filter + * disables the 100k skip. + */ +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +export {}; + +function dryRun(filter: string): string[] { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-matrix-')); + const stdout = execFileSync( + 'bash', + [ + '-c', + 'DRY_RUN=1 OUT_DIR="$1" bash scripts/run-matrix.sh "$2"', + 'bash', + outDir, + filter, + ], + { cwd: '.', encoding: 'utf8' }, + ); + return stdout + .split('\n') + .map((line: string) => line.trim()) + .filter((line: string) => /^(entity|endpoint|mixed)\//.test(line)); +} + +describe('run-matrix.sh filter', () => { + it('does not select 10000 or 100000 for a 1000 filter', () => { + for (const filter of ['1000', 'entity/unique/1000']) { + const specs = dryRun(filter); + expect(specs.length).toBeGreaterThan(0); + expect(specs.every((spec: string) => spec.split('/')[2] === '1000')).toBe( + true, + ); + } + }); + + it('runs /100000/ without FULL=1 and selects only 100k rows', () => { + const specs = dryRun('/100000/'); + expect(specs.length).toBeGreaterThan(0); + expect(specs.every((spec: string) => spec.split('/')[2] === '100000')).toBe( + true, + ); + }); + + it('matches the full stable id (mode segment between count and control)', () => { + expect(dryRun('unique/1000/interaction/no-gc')).toEqual([ + 'entity/unique/1000/no-gc', + 'endpoint/unique/1000/no-gc', + 'mixed/unique/1000/no-gc', + ]); + expect(dryRun('^android/mixed/unique/10000/')).toEqual([ + 'mixed/unique/10000/gc', + 'mixed/unique/10000/no-gc', + ]); + }); + + it('fails when nothing matches', () => { + for (const filter of ['entity/unique/1000/gc', 'widget/unique/1000']) { + expect(() => dryRun(filter)).toThrow(); + } + }); +}); diff --git a/examples/benchmark-native/__tests__/scenario-and-frames.test.ts b/examples/benchmark-native/__tests__/scenario-and-frames.test.ts new file mode 100644 index 000000000000..e223cfcc8949 --- /dev/null +++ b/examples/benchmark-native/__tests__/scenario-and-frames.test.ts @@ -0,0 +1,93 @@ +import { + computeMaxInputDelayMs, + excessMissedFramesFromIntervals, + frameIntervalsFromTimestamps, + median, + missedFramesFromTimestamps, +} from '../src/frames'; +import { splitMixedCount, scenarioId } from '../src/scenario'; + +describe('scenarioId', () => { + it('builds stable android interaction ids', () => { + expect( + scenarioId({ + candidateKind: 'entity', + pattern: 'unique', + count: 1000, + control: 'gc', + }), + ).toBe('android/entity/unique/1000/interaction/gc'); + }); + + it('rejects invalid axes', () => { + expect(() => + scenarioId({ + candidateKind: 'endpoint', + pattern: 'duplicate', + count: 1000, + control: 'gc', + }), + ).toThrow(/duplicate pattern only supports/); + }); +}); + +describe('splitMixedCount', () => { + it('splits evenly with remainder to entities', () => { + expect(splitMixedCount(1000)).toEqual({ entities: 500, endpoints: 500 }); + expect(splitMixedCount(1001)).toEqual({ entities: 501, endpoints: 500 }); + }); +}); + +describe('excessMissedFramesFromIntervals', () => { + it('counts nearest-period excess stalls', () => { + expect(excessMissedFramesFromIntervals([16.6], 16.6)).toBe(0); + expect(excessMissedFramesFromIntervals([33.2], 16.6)).toBe(1); + // slightly under 2× still rounds to 2 periods → 1 missed + expect(excessMissedFramesFromIntervals([1.98 * 16.6], 16.6)).toBe(1); + }); + + it('returns 0 for non-positive period', () => { + expect(excessMissedFramesFromIntervals([50], 0)).toBe(0); + }); +}); + +describe('missedFramesFromTimestamps', () => { + it('detects a multi-period stall in a pre→collect→post rAF sequence', () => { + const period = 16.67; + // pre @0, collect @period, then post only after ~3 periods (2 missed) + // This is what queueing the next rAF *before* blocking sweep enables. + const timestamps = [ + 0, + period, + period + 3 * period, // collect→post gap spans 3 periods + period + 3 * period + period, + ]; + expect(frameIntervalsFromTimestamps(timestamps)).toEqual([ + period, + 3 * period, + period, + ]); + expect(missedFramesFromTimestamps(timestamps, period)).toBe(2); + }); + + it('reports 0 missed when every interval is one display period', () => { + const period = 16.67; + const timestamps = [0, period, 2 * period, 3 * period]; + expect(missedFramesFromTimestamps(timestamps, period)).toBe(0); + }); +}); + +describe('computeMaxInputDelayMs', () => { + it('is max of timer delay and frame excess (not pointer latency)', () => { + expect(computeMaxInputDelayMs(5, [16.6, 40], 16.6)).toBe( + Math.max(5, 40 - 16.6), + ); + }); +}); + +describe('median', () => { + it('handles even and odd lengths', () => { + expect(median([1, 3, 2])).toBe(2); + expect(median([1, 2, 3, 4])).toBe(2.5); + }); +}); diff --git a/examples/benchmark-native/__tests__/source-digest.test.ts b/examples/benchmark-native/__tests__/source-digest.test.ts new file mode 100644 index 000000000000..435e8e776fa0 --- /dev/null +++ b/examples/benchmark-native/__tests__/source-digest.test.ts @@ -0,0 +1,159 @@ +/** + * Release sourceDigest must hash the files Metro puts in the Android bundle. + * Package exports are matched in the package's own key order, so `import` + * (node.mjs → dist) wins before the `react-native` field (lib/). + */ +const fs = require('fs'); +const { resolve } = require('metro-resolver'); +const os = require('os'); +const path = require('path'); + +const { + collectInputFiles, + hashInputFiles, + metroBundledPackageFiles, + resolveMetroFile, + sourceDigest, +} = require('../scripts/build-manifest.cjs'); + +export {}; + +// Jest cwd is this workspace (examples/benchmark-native). +const REPO = path.resolve('../..'); +const ORIGIN = path.resolve('src/gcHarness.ts'); + +function rel(file: string): string { + return path.relative(REPO, file).split(path.sep).join('/'); +} + +describe('android sourceDigest inputs', () => { + it('hashes the files Metro resolves, not the react-native lib field', () => { + const esmEntry = resolveMetroFile(ORIGIN, '@data-client/core', true); + const cjsEntry = resolveMetroFile(ORIGIN, '@data-client/core', false); + expect(rel(esmEntry)).toBe('packages/core/node.mjs'); + expect(rel(cjsEntry)).toBe('packages/core/dist/index.js'); + + const bundled = metroBundledPackageFiles().map(rel); + expect(bundled).toEqual([ + 'packages/core/dist/index.js', + 'packages/core/node.mjs', + 'packages/normalizr/dist/normalizr.js', + ]); + + const rels: string[] = collectInputFiles().map(rel); + for (const file of bundled) { + expect(rels).toContain(file); + } + expect(rels.some((r: string) => r.startsWith('packages/core/lib/'))).toBe( + false, + ); + expect(rels.some((r: string) => r.startsWith('packages/core/src/'))).toBe( + false, + ); + expect( + rels.some((r: string) => r.startsWith('packages/normalizr/lib/')), + ).toBe(false); + expect(rels.some((r: string) => r.startsWith('packages/endpoint/'))).toBe( + false, + ); + expect(rels).toContain('examples/gc-shared/protocol.js'); + expect(sourceDigest()).toBe(hashInputFiles(collectInputFiles())); + }); + + it('asks metro-resolver for the release entry and uses that path', () => { + const config = require('../metro.config.js'); + const resolver = config.resolver; + const origin = ORIGIN; + function lookup(filePath: string) { + try { + const stat = fs.lstatSync(filePath); + return { + exists: true, + type: stat.isDirectory() ? 'd' : 'f', + realPath: fs.realpathSync(filePath), + }; + } catch { + return { exists: false }; + } + } + function getPackageForModule(abs: string) { + let dir = abs; + try { + if (!fs.statSync(abs).isDirectory()) dir = path.dirname(abs); + } catch { + dir = path.dirname(abs); + } + while (true) { + const pkgPath = path.join(dir, 'package.json'); + if (fs.existsSync(pkgPath)) { + const relative = path.relative(dir, abs); + return { + rootPath: dir, + packageJson: JSON.parse(fs.readFileSync(pkgPath, 'utf8')), + packageRelativePath: relative === '' ? '' : relative, + }; + } + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } + } + const resolved = resolve( + { + allowHaste: false, + assetExts: new Set(resolver.assetExts), + dev: false, + disableHierarchicalLookup: !!resolver.disableHierarchicalLookup, + doesFileExist: (filePath: string) => { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } + }, + extraNodeModules: resolver.extraNodeModules || {}, + fileSystemLookup: lookup, + getPackage: (pkgPath: string) => + JSON.parse(fs.readFileSync(pkgPath, 'utf8')), + getPackageForModule, + isESMImport: true, + mainFields: resolver.resolverMainFields, + nodeModulesPaths: resolver.nodeModulesPaths, + originModulePath: origin, + preferNativePlatform: true, + resolveAsset: () => null, + resolveRequest: resolver.resolveRequest || null, + sourceExts: resolver.sourceExts, + unstable_conditionNames: resolver.unstable_conditionNames, + unstable_conditionsByPlatform: resolver.unstable_conditionsByPlatform, + unstable_enablePackageExports: resolver.unstable_enablePackageExports, + unstable_logWarning: () => {}, + }, + '@data-client/core', + 'android', + ); + expect(rel(resolved.filePath)).toBe('packages/core/node.mjs'); + expect(metroBundledPackageFiles().map(rel)).toContain( + 'packages/core/node.mjs', + ); + expect(rel(resolved.filePath)).not.toContain('/lib/'); + }); + + it('changes when a bundled dist file changes and ignores package src', () => { + const dist = collectInputFiles().find((file: string) => + rel(file).endsWith('packages/core/dist/index.js'), + ); + expect(dist).toBeDefined(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-digest-')); + const copy = path.join(dir, 'index.js'); + const bytes = fs.readFileSync(dist, 'utf8'); + fs.writeFileSync(copy, bytes); + const before = hashInputFiles([copy]); + fs.writeFileSync(copy, `${bytes}\n`); + expect(hashInputFiles([copy])).not.toBe(before); + + const src = path.join(REPO, 'packages/core/src/index.ts'); + expect(fs.existsSync(src)).toBe(true); + expect(collectInputFiles().includes(path.resolve(src))).toBe(false); + }); +}); diff --git a/examples/benchmark-native/__tests__/summarize.test.ts b/examples/benchmark-native/__tests__/summarize.test.ts new file mode 100644 index 000000000000..404bb56c6b0e --- /dev/null +++ b/examples/benchmark-native/__tests__/summarize.test.ts @@ -0,0 +1,93 @@ +import { summarizeGCSamples } from '../src/report'; +import type { GCAndroidMeasurement } from '../src/types'; + +function baseSample( + overrides: Partial = {}, +): GCAndroidMeasurement { + return { + schemaVersion: 1, + totalMs: 10, + sliceDurationsMs: [10], + actionCount: 1, + queueEntries: 1000, + uniqueTargets: 1000, + actionTargetCount: 1000, + deletionCount: 1000, + timerDelayMs: 1, + frameIntervalsMs: [16], + displayPeriodMs: 16, + missedFrames: 0, + maxInputDelayMs: 1, + uiCaptureSource: 'FrameMetrics', + uiFrameCount: 10, + uiMaxFrameDurationMs: 16, + uiTotalFrameDurationMs: 160, + uiMissedFrames: 0, + uiDroppedFrameMetrics: 0, + uiFrameMetricsDropped: false, + uiRefreshPeriodMs: 16.67, + uiRefreshRateHz: 60, + ...overrides, + }; +} + +describe('summarizeGCSamples', () => { + it('summarizes UI frame and memory delta fields when present', () => { + const summary = summarizeGCSamples([ + baseSample({ + uiFrameCount: 10, + uiMaxFrameDurationMs: 20, + uiTotalFrameDurationMs: 160, + uiMissedFrames: 1, + uiDroppedFrameMetrics: 2, + uiFrameMetricsDropped: true, + processPssBeforeKb: 100, + processPssAfterKb: 90, + processPssDeltaKb: -10, + processRssBeforeKb: 200, + processRssAfterKb: 180, + processRssDeltaKb: -20, + jsHeapBeforeBytes: 1000, + jsHeapAfterBytes: 800, + jsHeapDeltaBytes: -200, + }), + baseSample({ + uiFrameCount: 12, + uiMaxFrameDurationMs: 22, + uiTotalFrameDurationMs: 180, + uiMissedFrames: 0, + uiDroppedFrameMetrics: 0, + uiFrameMetricsDropped: false, + processPssBeforeKb: 110, + processPssAfterKb: 95, + processPssDeltaKb: -15, + processRssBeforeKb: 210, + processRssAfterKb: 185, + processRssDeltaKb: -25, + jsHeapBeforeBytes: 1100, + jsHeapAfterBytes: 850, + jsHeapDeltaBytes: -250, + }), + ]); + + expect(summary.uiFrameCount?.median).toBe(11); + expect(summary.uiTotalFrameDurationMs?.min).toBe(160); + expect(summary.uiMaxFrameDurationMs?.max).toBe(22); + expect(summary.uiMissedFrames?.median).toBe(0.5); + expect(summary.uiDroppedFrameMetrics?.median).toBe(1); + expect(summary.processPssBeforeKb?.median).toBe(105); + expect(summary.processPssAfterKb?.median).toBe(92.5); + expect(summary.processPssDeltaKb?.median).toBe(-12.5); + expect(summary.processRssDeltaKb?.median).toBe(-22.5); + expect(summary.jsHeapDeltaBytes?.median).toBe(-225); + }); + + it('omits optional memory summaries when unavailable', () => { + const summary = summarizeGCSamples([baseSample()]); + expect(summary.processPssDeltaKb).toBeUndefined(); + expect(summary.processRssBeforeKb).toBeUndefined(); + expect(summary.jsHeapBeforeBytes).toBeUndefined(); + // UI aggregates are required on every sample + expect(summary.uiFrameCount?.median).toBe(10); + }); +}); diff --git a/examples/benchmark-native/__tests__/validate-config.test.ts b/examples/benchmark-native/__tests__/validate-config.test.ts new file mode 100644 index 000000000000..5df13263e92b --- /dev/null +++ b/examples/benchmark-native/__tests__/validate-config.test.ts @@ -0,0 +1,118 @@ +import { + ConfigValidationError, + MAX_SAMPLES, + parseLaunchConfig, + validateSampleCount, + validateScenarioConfig, +} from '../src/validateConfig'; + +describe('validateScenarioConfig', () => { + it('accepts canonical axes', () => { + expect(() => + validateScenarioConfig({ + candidateKind: 'mixed', + pattern: 'unique', + count: 100000, + control: 'no-gc', + }), + ).not.toThrow(); + }); + + it('rejects non-canonical counts', () => { + expect(() => + validateScenarioConfig({ + candidateKind: 'entity', + pattern: 'unique', + count: 500, + control: 'gc', + }), + ).toThrow(ConfigValidationError); + }); + + it('rejects duplicate with non-entity', () => { + expect(() => + validateScenarioConfig({ + candidateKind: 'endpoint', + pattern: 'duplicate', + count: 1000, + control: 'gc', + }), + ).toThrow(/duplicate pattern only supports/); + }); + + it('rejects invalid enums', () => { + expect(() => + validateScenarioConfig({ + candidateKind: 'widget' as any, + pattern: 'unique', + count: 1000, + control: 'gc', + }), + ).toThrow(/candidateKind/); + expect(() => + validateScenarioConfig({ + candidateKind: 'entity', + pattern: 'unique', + count: 1000, + control: 'maybe' as any, + }), + ).toThrow(/control/); + }); +}); + +describe('validateSampleCount', () => { + it('accepts 1..MAX_SAMPLES', () => { + expect(() => validateSampleCount(1)).not.toThrow(); + expect(() => validateSampleCount(MAX_SAMPLES)).not.toThrow(); + }); + + it('rejects 0, negative, and oversized', () => { + expect(() => validateSampleCount(0)).toThrow(ConfigValidationError); + expect(() => validateSampleCount(-1)).toThrow(ConfigValidationError); + expect(() => validateSampleCount(MAX_SAMPLES + 1)).toThrow( + ConfigValidationError, + ); + }); +}); + +describe('parseLaunchConfig', () => { + it('applies defaults then validates', () => { + const parsed = parseLaunchConfig({ autoRun: true }); + expect(parsed).toMatchObject({ + autoRun: true, + candidateKind: 'entity', + pattern: 'unique', + count: 1000, + control: 'gc', + samples: 1, + }); + expect(parsed.label).toBeUndefined(); + }); + + it('accepts optional label without commit authority', () => { + const parsed = parseLaunchConfig({ + autoRun: false, + label: 'baseline', + count: 1000, + }); + expect(parsed.label).toBe('baseline'); + expect((parsed as { commit?: string }).commit).toBeUndefined(); + }); + + it('fails clearly on invalid intent-like input', () => { + expect(() => parseLaunchConfig({ autoRun: true, count: 999 })).toThrow( + /invalid count/, + ); + expect(() => + parseLaunchConfig({ + autoRun: true, + candidateKind: 'endpoint', + pattern: 'duplicate', + count: 1000, + }), + ).toThrow(/duplicate/); + expect(() => parseLaunchConfig({ autoRun: true, samples: 0 })).toThrow( + /samples/, + ); + }); +}); diff --git a/examples/benchmark-native/android/app/build.gradle b/examples/benchmark-native/android/app/build.gradle new file mode 100644 index 000000000000..4f4a4ae2a171 --- /dev/null +++ b/examples/benchmark-native/android/app/build.gradle @@ -0,0 +1,118 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +/** + * This is the configuration block to customize your React Native Android app. + * By default you don't need to apply any configuration, just uncomment the lines you need. + */ +react { + /* Folders */ + // The root of your project, i.e. where "package.json" lives. Default is '../..' + root = file("../../") + // Monorepo: react-native is hoisted to the workspace root node_modules. + reactNativeDir = file("../../../../node_modules/react-native") + codegenDir = file("../../../../node_modules/@react-native/codegen") + cliFile = file("../../../../node_modules/react-native/cli.js") + + /* Variants */ + // The list of variants to that are debuggable. For those we're going to + // skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized". + // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. + // debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"] + + /* Bundling */ + // A list containing the node command and its flags. Default is just 'node'. + // nodeExecutableAndArgs = ["node"] + // + // The command to run when bundling. Default is 'bundle' + // bundleCommand = "ram-bundle" + // + // The path to the CLI configuration file. Default is empty. + // bundleConfig = file(../rn-cli.config.js) + // + // The name of the generated asset file containing your JS bundle + // bundleAssetName = "MyApplication.android.bundle" + // + // The entry file for bundle generation. Default is 'index.android.js' or 'index.js' + // entryFile = file("../js/MyApplication.android.js") + // + // A list of extra flags to pass to the 'bundle' commands. + // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle + // extraPackagerArgs = [] + + /* Hermes Commands */ + // Monorepo: hermesc lives in hermes-compiler (absolute + %OS-BIN% token). + // projectDir is android/app; rootDir is android/ — use projectDir for path math. + hermesCommand = "${new File(projectDir, '../../../../node_modules/hermes-compiler/hermesc').canonicalPath}/%OS-BIN%/hermesc" + // + // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" + // hermesFlags = ["-O", "-output-source-map"] + + /* Autolinking */ + autolinkLibrariesWithApp() +} + +/** + * Set this to true to Run Proguard on Release builds to minify the Java bytecode. + */ +def enableProguardInReleaseBuilds = false + +/** + * The preferred build flavor of JavaScriptCore (JSC) + * + * For example, to use the international variant, you can use: + * `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+` + * + * The international variant includes ICU i18n library and necessary data + * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that + * give correct results when using with locales other than en-US. Note that + * this variant is about 6MiB larger per architecture than default. + */ +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' + +android { + ndkVersion rootProject.ext.ndkVersion + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdk rootProject.ext.compileSdkVersion + + namespace "com.dataclient.benchmarknative" + defaultConfig { + applicationId "com.dataclient.benchmarknative" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + // Caution! In production, you need to generate your own keystore file. + // see https://reactnative.dev/docs/signed-apk-android. + signingConfig signingConfigs.debug + minifyEnabled enableProguardInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation("com.facebook.react:react-android") + + if (hermesEnabled.toBoolean()) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } +} diff --git a/examples/benchmark-native/android/app/debug.keystore b/examples/benchmark-native/android/app/debug.keystore new file mode 100644 index 000000000000..364e105ed39f Binary files /dev/null and b/examples/benchmark-native/android/app/debug.keystore differ diff --git a/examples/benchmark-native/android/app/proguard-rules.pro b/examples/benchmark-native/android/app/proguard-rules.pro new file mode 100644 index 000000000000..11b025724a3f --- /dev/null +++ b/examples/benchmark-native/android/app/proguard-rules.pro @@ -0,0 +1,10 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: diff --git a/examples/benchmark-native/android/app/src/main/AndroidManifest.xml b/examples/benchmark-native/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..fb78f39746c2 --- /dev/null +++ b/examples/benchmark-native/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt new file mode 100644 index 000000000000..dbf274587aab --- /dev/null +++ b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt @@ -0,0 +1,465 @@ +package com.dataclient.benchmarknative + +import android.app.ActivityManager +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.os.Debug +import android.os.Environment +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import android.util.Log +import android.view.Choreographer +import android.view.FrameMetrics +import android.view.Window +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.WritableMap +import java.io.File +import java.lang.ref.WeakReference +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * Aggregate-only native probes: UI frame timing (no per-frame bridge calls), + * process memory snapshots, launch extras, report I/O, and embedded build manifest. + * + * FrameMetrics TOTAL_DURATION is a duration → ceil(duration/period)−1. + * dropCountSinceLastInvocation is a separate loss count, not a missed frame. + * Choreographer deltas are intervals → round(interval/period)−1. + */ +class BenchNativeModule( + private val reactContext: ReactApplicationContext, +) : ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String = NAME + + private val mainHandler = Handler(Looper.getMainLooper()) + private var capturing = false + private var captureSource: String = "none" + private var frameMetricsListener: Window.OnFrameMetricsAvailableListener? = null + private var captureWindowRef: WeakReference? = null + private var choreographerCallback: Choreographer.FrameCallback? = null + private var lastChoreographerNs: Long = 0L + + private var frameCount = 0 + private var maxFrameDurationNs = 0L + private var totalFrameDurationNs = 0L + private var missedFrames = 0 + /** Sum of FrameMetrics dropCountSinceLastInvocation. Not added to missedFrames. */ + private var droppedFrameMetrics = 0 + private var refreshPeriodNs = 16_666_666L + private var refreshRateHz = 60.0 + + override fun invalidate() { + // Tear down listeners on the main thread so activity recreation cannot leak. + mainHandler.post { stopCaptureInternal(idempotent = true) } + super.invalidate() + } + + @ReactMethod + fun getLaunchConfig(promise: Promise) { + try { + val extras = MainActivity.launchExtras + val map = Arguments.createMap() + map.putBoolean("autoRun", extras?.getBoolean("autoRun", false) ?: false) + putExtraString(map, extras, "candidateKind") + putExtraString(map, extras, "pattern") + putExtraString(map, extras, "control") + putExtraString(map, extras, "label") + if (extras != null && extras.containsKey("count")) { + map.putInt("count", extras.getInt("count", 1000)) + } + if (extras != null && extras.containsKey("samples")) { + map.putInt("samples", extras.getInt("samples", 1)) + } + promise.resolve(map) + } catch (e: Exception) { + promise.reject("LAUNCH_CONFIG", e) + } + } + + @ReactMethod + fun getBuildManifest(promise: Promise) { + try { + val json = + reactContext.assets.open(BUILD_MANIFEST_ASSET).bufferedReader().use { it.readText() } + val map = Arguments.createMap() + map.putString("json", json) + promise.resolve(map) + } catch (e: Exception) { + promise.reject( + "BUILD_MANIFEST", + "build-manifest.json missing — run yarn build:android:release (prepare→gradle→finalize)", + e, + ) + } + } + + @ReactMethod + fun getEnvironment(promise: Promise) { + try { + val activity = reactContext.currentActivity + val display = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + activity?.display + } else { + @Suppress("DEPRECATION") + activity?.windowManager?.defaultDisplay + } + val hz = display?.refreshRate?.toDouble() ?: 60.0 + val periodMs = if (hz > 0) 1000.0 / hz else 1000.0 / 60.0 + + val map = Arguments.createMap() + map.putInt("apiLevel", Build.VERSION.SDK_INT) + map.putString("release", Build.VERSION.RELEASE ?: "") + map.putString("manufacturer", Build.MANUFACTURER ?: "") + map.putString("model", Build.MODEL ?: "") + map.putString("device", Build.DEVICE ?: "") + map.putString("brand", Build.BRAND ?: "") + map.putString( + "buildType", + if (BuildConfig.DEBUG) "debug" else "release", + ) + map.putString("applicationId", BuildConfig.APPLICATION_ID) + map.putBoolean("hermesEnabled", isHermesEnabled()) + map.putDouble("refreshRateHz", hz) + map.putDouble("refreshPeriodMs", periodMs) + + val hermesProps = Arguments.createMap() + hermesProps.putString("BuildConfig.IS_HERMES_ENABLED", isHermesEnabled().toString()) + try { + val clazz = Class.forName("com.facebook.react.common.build.ReactBuildConfig") + val field = clazz.getField("IS_HERMES_ENABLED") + hermesProps.putString("ReactBuildConfig.IS_HERMES_ENABLED", field.getBoolean(null).toString()) + } catch (_: Throwable) { + // optional + } + map.putMap("hermesRuntimeProperties", hermesProps) + + promise.resolve(map) + } catch (e: Exception) { + promise.reject("ENVIRONMENT", e) + } + } + + @ReactMethod + fun getMemorySnapshot(promise: Promise) { + try { + val info = Debug.MemoryInfo() + Debug.getMemoryInfo(info) + val map = Arguments.createMap() + map.putInt("totalPssKb", info.totalPss) + map.putInt("totalPrivateDirtyKb", info.totalPrivateDirty) + if (Build.VERSION.SDK_INT >= 34) { + try { + val rssBytes = + android.os.Process::class.java + .getMethod("getRssInBytes") + .invoke(null) as Long + map.putDouble("rssKb", rssBytes / 1024.0) + } catch (_: Throwable) { + // omit rssKb + } + } + val am = reactContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + map.putInt("memoryClassMb", am.memoryClass) + promise.resolve(map) + } catch (e: Exception) { + promise.reject("MEMORY", e) + } + } + + @ReactMethod + fun startUiFrameCapture(promise: Promise) { + mainHandler.post { + try { + if (capturing) { + promise.reject("ALREADY_CAPTURING", "UI frame capture already running") + return@post + } + resetCaptureCounters() + val activity = reactContext.currentActivity + if (activity == null) { + promise.reject("NO_ACTIVITY", "No current activity for frame capture") + return@post + } + updateRefreshPeriod(activity) + if (refreshPeriodNs <= 0L || refreshRateHz <= 0.0) { + promise.reject("INVALID_REFRESH", "refresh period/rate must be positive") + return@post + } + + val window = activity.window + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && window != null) { + val listener = + Window.OnFrameMetricsAvailableListener { _, frameMetrics, dropCountSinceLastInvocation -> + recordFrameMetricsDrop(dropCountSinceLastInvocation) + val totalNs = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) + recordFrameMetricsDuration(totalNs) + } + window.addOnFrameMetricsAvailableListener(listener, mainHandler) + frameMetricsListener = listener + captureWindowRef = WeakReference(window) + captureSource = "FrameMetrics" + capturing = true + val result = Arguments.createMap() + result.putBoolean("started", true) + result.putString("source", captureSource) + promise.resolve(result) + } else { + startChoreographerCapture(promise) + } + } catch (e: Exception) { + try { + startChoreographerCapture(promise) + } catch (e2: Exception) { + promise.reject("FRAME_CAPTURE_START", e2) + } + } + } + } + + @ReactMethod + fun stopUiFrameCapture(promise: Promise) { + mainHandler.post { + try { + // Idempotent: safe for finally/cleanup when already stopped. + val map = stopCaptureInternal(idempotent = true) + promise.resolve(map) + } catch (e: Exception) { + capturing = false + promise.reject("FRAME_CAPTURE_STOP", e) + } + } + } + + @ReactMethod + fun writeReport(json: String, promise: Promise) { + try { + val file = writeAppExternalReport(json) + // Q+: /Android/data is often hidden from adb shell on user builds of a + // non-debuggable release APK. Mirror to Downloads so collect can pull. + val pullFile = writePublicPullCopy(json) ?: file + Log.i( + LOG_TAG, + "REPORT_READY path=${file.absolutePath} pull=${pullFile.absolutePath} bytes=${json.length}", + ) + val map = Arguments.createMap() + map.putString("path", file.absolutePath) + map.putString("pullPath", pullFile.absolutePath) + promise.resolve(map) + } catch (e: Exception) { + promise.reject("WRITE_REPORT", e) + } + } + + /** App-specific external storage — no extra permission; preferred report file. */ + private fun writeAppExternalReport(json: String): File { + val dir = + reactContext.getExternalFilesDir(null) + ?: throw IllegalStateException( + "app-specific external storage unavailable; cannot write adb-pullable report", + ) + if (!dir.exists() && !dir.mkdirs()) { + throw IllegalStateException("failed to create report dir ${dir.absolutePath}") + } + val file = File(dir, REPORT_FILE) + file.writeText(json) + return file + } + + /** + * Public Downloads copy for `adb pull` on Android 10+ user builds. Pre-Q, + * `externalFilesDir` is already shell-visible so no extra copy is needed. + */ + private fun writePublicPullCopy(json: String): File? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null + val resolver = reactContext.contentResolver + resolver.delete( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + "${MediaStore.MediaColumns.DISPLAY_NAME}=?", + arrayOf(PUBLIC_REPORT_FILE), + ) + val values = + ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, PUBLIC_REPORT_FILE) + put(MediaStore.Downloads.MIME_TYPE, "application/json") + put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/") + put(MediaStore.Downloads.IS_PENDING, 1) + } + val uri = + resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + ?: throw IllegalStateException("MediaStore insert failed for $PUBLIC_REPORT_FILE") + try { + val out = + resolver.openOutputStream(uri, "w") + ?: throw IllegalStateException("no output stream for $uri") + out.use { it.write(json.toByteArray()) } + val done = ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) } + resolver.update(uri, done, null, null) + } catch (e: Exception) { + resolver.delete(uri, null, null) + throw e + } + return File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + PUBLIC_REPORT_FILE, + ) + } + + private fun stopCaptureInternal(idempotent: Boolean): WritableMap { + if (capturing) { + val listener = frameMetricsListener + val window = captureWindowRef?.get() ?: reactContext.currentActivity?.window + if (listener != null && window != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + try { + window.removeOnFrameMetricsAvailableListener(listener) + } catch (_: Throwable) { + // ignore — activity may already be gone + } + } + frameMetricsListener = null + captureWindowRef = null + + val cb = choreographerCallback + if (cb != null) { + try { + Choreographer.getInstance().removeFrameCallback(cb) + } catch (_: Throwable) { + // ignore + } + } + choreographerCallback = null + capturing = false + } else if (!idempotent) { + throw IllegalStateException("UI frame capture is not running") + } + + val map = Arguments.createMap() + map.putString("source", captureSource) + map.putInt("frameCount", frameCount) + map.putDouble("maxFrameDurationMs", maxFrameDurationNs / 1_000_000.0) + map.putDouble("totalFrameDurationMs", totalFrameDurationNs / 1_000_000.0) + map.putInt("missedFrames", missedFrames) + map.putInt("droppedFrameMetrics", droppedFrameMetrics) + map.putBoolean("frameMetricsDropped", droppedFrameMetrics > 0) + map.putDouble("refreshPeriodMs", refreshPeriodNs / 1_000_000.0) + map.putDouble("refreshRateHz", refreshRateHz) + map.putBoolean("wasCapturing", frameCount > 0 || captureSource != "none") + return map + } + + private fun startChoreographerCapture(promise: Promise) { + lastChoreographerNs = 0L + val callback = + object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + if (!capturing) return + if (lastChoreographerNs > 0L) { + recordChoreographerInterval(frameTimeNanos - lastChoreographerNs) + } + lastChoreographerNs = frameTimeNanos + Choreographer.getInstance().postFrameCallback(this) + } + } + choreographerCallback = callback + captureSource = "Choreographer" + capturing = true + Choreographer.getInstance().postFrameCallback(callback) + val result = Arguments.createMap() + result.putBoolean("started", true) + result.putString("source", captureSource) + promise.resolve(result) + } + + /** + * Lost FrameMetrics reports since the previous callback. + * Kept separate from missedFrames / the Choreographer interval count. + */ + private fun recordFrameMetricsDrop(dropCount: Int) { + if (dropCount <= 0) return + droppedFrameMetrics += dropCount + } + + /** FrameMetrics TOTAL_DURATION — duration semantics (ceil). */ + private fun recordFrameMetricsDuration(durationNs: Long) { + if (durationNs <= 0L) return + frameCount++ + totalFrameDurationNs += durationNs + if (durationNs > maxFrameDurationNs) { + maxFrameDurationNs = durationNs + } + if (refreshPeriodNs > 0L) { + val periods = ceil(durationNs.toDouble() / refreshPeriodNs.toDouble()).toInt() + missedFrames += max(0, periods - 1) + } + } + + /** Choreographer frame-time delta — interval semantics (round). */ + private fun recordChoreographerInterval(intervalNs: Long) { + if (intervalNs <= 0L) return + frameCount++ + totalFrameDurationNs += intervalNs + if (intervalNs > maxFrameDurationNs) { + maxFrameDurationNs = intervalNs + } + if (refreshPeriodNs > 0L) { + val periods = (intervalNs.toDouble() / refreshPeriodNs.toDouble()).roundToInt() + missedFrames += max(0, periods - 1) + } + } + + private fun resetCaptureCounters() { + frameCount = 0 + maxFrameDurationNs = 0L + totalFrameDurationNs = 0L + missedFrames = 0 + droppedFrameMetrics = 0 + lastChoreographerNs = 0L + captureSource = "none" + captureWindowRef = null + } + + private fun updateRefreshPeriod(activity: android.app.Activity) { + val display = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + activity.display + } else { + @Suppress("DEPRECATION") + activity.windowManager?.defaultDisplay + } + val hz = display?.refreshRate?.toDouble() ?: 60.0 + refreshRateHz = if (hz > 0) hz else 60.0 + refreshPeriodNs = (1_000_000_000.0 / refreshRateHz).toLong() + } + + private fun isHermesEnabled(): Boolean { + return try { + BuildConfig::class.java.getField("IS_HERMES_ENABLED").getBoolean(null) + } catch (_: Throwable) { + true + } + } + + private fun putExtraString(map: WritableMap, extras: Bundle?, key: String) { + val value = extras?.getString(key) + if (value != null) { + map.putString(key, value) + } + } + + companion object { + const val NAME = "BenchNative" + const val LOG_TAG = "BenchNative" + const val REPORT_FILE = "gc-report.json" + const val PUBLIC_REPORT_FILE = "dataclient-gc-report.json" + const val BUILD_MANIFEST_ASSET = "build-manifest.json" + } +} diff --git a/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativePackage.kt b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativePackage.kt new file mode 100644 index 000000000000..44afba0725ef --- /dev/null +++ b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativePackage.kt @@ -0,0 +1,17 @@ +package com.dataclient.benchmarknative + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class BenchNativePackage : ReactPackage { + @Deprecated("ReactPackage.createNativeModules is deprecated") + override fun createNativeModules( + reactContext: ReactApplicationContext, + ): List = listOf(BenchNativeModule(reactContext)) + + override fun createViewManagers( + reactContext: ReactApplicationContext, + ): List> = emptyList() +} diff --git a/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/MainActivity.kt b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/MainActivity.kt new file mode 100644 index 000000000000..d3fbafe41304 --- /dev/null +++ b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/MainActivity.kt @@ -0,0 +1,30 @@ +package com.dataclient.benchmarknative + +import android.os.Bundle +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +class MainActivity : ReactActivity() { + + override fun getMainComponentName(): String = "BenchmarkNative" + + override fun onCreate(savedInstanceState: Bundle?) { + launchExtras = intent?.extras + super.onCreate(savedInstanceState) + } + + override fun onNewIntent(intent: android.content.Intent) { + super.onNewIntent(intent) + setIntent(intent) + launchExtras = intent.extras + } + + override fun createReactActivityDelegate(): ReactActivityDelegate = + DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) + + companion object { + @JvmField var launchExtras: Bundle? = null + } +} diff --git a/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/MainApplication.kt b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/MainApplication.kt new file mode 100644 index 000000000000..b5ba9ae76bcb --- /dev/null +++ b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/MainApplication.kt @@ -0,0 +1,26 @@ +package com.dataclient.benchmarknative + +import android.app.Application +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost + +class MainApplication : Application(), ReactApplication { + + override val reactHost: ReactHost by lazy { + getDefaultReactHost( + context = applicationContext, + packageList = + PackageList(this).packages.apply { + add(BenchNativePackage()) + }, + ) + } + + override fun onCreate() { + super.onCreate() + loadReactNative(this) + } +} diff --git a/examples/benchmark-native/android/app/src/main/res/drawable/rn_edit_text_material.xml b/examples/benchmark-native/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 000000000000..5c25e728ea2c --- /dev/null +++ b/examples/benchmark-native/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/examples/benchmark-native/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/examples/benchmark-native/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000000..a2f5908281d0 Binary files /dev/null and b/examples/benchmark-native/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/examples/benchmark-native/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/examples/benchmark-native/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000000..1b5239980811 Binary files /dev/null and b/examples/benchmark-native/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/examples/benchmark-native/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/examples/benchmark-native/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000000..ff10afd6e182 Binary files /dev/null and b/examples/benchmark-native/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/examples/benchmark-native/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/examples/benchmark-native/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000000..115a4c768a20 Binary files /dev/null and b/examples/benchmark-native/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/examples/benchmark-native/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/examples/benchmark-native/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000000..dcd3cd808335 Binary files /dev/null and b/examples/benchmark-native/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/examples/benchmark-native/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/examples/benchmark-native/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000000..459ca609d3ae Binary files /dev/null and b/examples/benchmark-native/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/examples/benchmark-native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/examples/benchmark-native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000000..8ca12fe024be Binary files /dev/null and b/examples/benchmark-native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/examples/benchmark-native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/examples/benchmark-native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000000..8e19b410a1b1 Binary files /dev/null and b/examples/benchmark-native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/examples/benchmark-native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/examples/benchmark-native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000000..b824ebdd48db Binary files /dev/null and b/examples/benchmark-native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/examples/benchmark-native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/examples/benchmark-native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000000..4c19a13c239c Binary files /dev/null and b/examples/benchmark-native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/examples/benchmark-native/android/app/src/main/res/values/strings.xml b/examples/benchmark-native/android/app/src/main/res/values/strings.xml new file mode 100644 index 000000000000..a3a26959f049 --- /dev/null +++ b/examples/benchmark-native/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + BenchmarkNative + diff --git a/examples/benchmark-native/android/app/src/main/res/values/styles.xml b/examples/benchmark-native/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000000..7ba83a2ad5a2 --- /dev/null +++ b/examples/benchmark-native/android/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/examples/benchmark-native/android/build.gradle b/examples/benchmark-native/android/build.gradle new file mode 100644 index 000000000000..dad99b022ac9 --- /dev/null +++ b/examples/benchmark-native/android/build.gradle @@ -0,0 +1,21 @@ +buildscript { + ext { + buildToolsVersion = "36.0.0" + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + ndkVersion = "27.1.12297006" + kotlinVersion = "2.1.20" + } + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle") + classpath("com.facebook.react:react-native-gradle-plugin") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") + } +} + +apply plugin: "com.facebook.react.rootproject" diff --git a/examples/benchmark-native/android/gradle.properties b/examples/benchmark-native/android/gradle.properties new file mode 100644 index 000000000000..9afe61598fc5 --- /dev/null +++ b/examples/benchmark-native/android/gradle.properties @@ -0,0 +1,44 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew -PreactNativeArchitectures=x86_64 +reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. +newArchEnabled=true + +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +# Use this property to enable edge-to-edge display support. +# This allows your app to draw behind system bars for an immersive UI. +# Note: Only works with ReactActivity and should not be used with custom Activity. +edgeToEdgeEnabled=false diff --git a/examples/benchmark-native/android/gradle/wrapper/gradle-wrapper.jar b/examples/benchmark-native/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000000..61285a659d17 Binary files /dev/null and b/examples/benchmark-native/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/benchmark-native/android/gradle/wrapper/gradle-wrapper.properties b/examples/benchmark-native/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..37f78a6af837 --- /dev/null +++ b/examples/benchmark-native/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/benchmark-native/android/gradlew b/examples/benchmark-native/android/gradlew new file mode 100644 index 000000000000..adff685a0348 --- /dev/null +++ b/examples/benchmark-native/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/benchmark-native/android/gradlew.bat b/examples/benchmark-native/android/gradlew.bat new file mode 100644 index 000000000000..39baf4d68461 --- /dev/null +++ b/examples/benchmark-native/android/gradlew.bat @@ -0,0 +1,98 @@ +@REM Copyright (c) Meta Platforms, Inc. and affiliates. +@REM +@REM This source code is licensed under the MIT license found in the +@REM LICENSE file in the root directory of this source tree. + +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/benchmark-native/android/settings.gradle b/examples/benchmark-native/android/settings.gradle new file mode 100644 index 000000000000..dbaef8911829 --- /dev/null +++ b/examples/benchmark-native/android/settings.gradle @@ -0,0 +1,6 @@ +pluginManagement { includeBuild("../../../node_modules/@react-native/gradle-plugin") } +plugins { id("com.facebook.react.settings") } +extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } +rootProject.name = 'BenchmarkNative' +include ':app' +includeBuild('../../../node_modules/@react-native/gradle-plugin') diff --git a/examples/benchmark-native/app.json b/examples/benchmark-native/app.json new file mode 100644 index 000000000000..70edac4556d2 --- /dev/null +++ b/examples/benchmark-native/app.json @@ -0,0 +1,4 @@ +{ + "name": "BenchmarkNative", + "displayName": "BenchmarkNative" +} diff --git a/examples/benchmark-native/babel.config.js b/examples/benchmark-native/babel.config.js new file mode 100644 index 000000000000..f7b3da3b33d1 --- /dev/null +++ b/examples/benchmark-native/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:@react-native/babel-preset'], +}; diff --git a/examples/benchmark-native/index.js b/examples/benchmark-native/index.js new file mode 100644 index 000000000000..016f62c2dd9d --- /dev/null +++ b/examples/benchmark-native/index.js @@ -0,0 +1,10 @@ +/** + * @format + */ + +import { AppRegistry } from 'react-native'; + +import App from './App'; +import { name as appName } from './app.json'; + +AppRegistry.registerComponent(appName, () => App); diff --git a/examples/benchmark-native/jest.config.js b/examples/benchmark-native/jest.config.js new file mode 100644 index 000000000000..1c2b0e3c866c --- /dev/null +++ b/examples/benchmark-native/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: 'react-native', + testPathIgnorePatterns: ['/node_modules/', '/android/'], + modulePathIgnorePatterns: ['/android/'], +}; diff --git a/examples/benchmark-native/metro.config.js b/examples/benchmark-native/metro.config.js new file mode 100644 index 000000000000..fdf64ff51740 --- /dev/null +++ b/examples/benchmark-native/metro.config.js @@ -0,0 +1,23 @@ +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +const path = require('path'); + +const projectRoot = __dirname; +const monorepoRoot = path.resolve(projectRoot, '../..'); + +/** + * Metro configuration for yarn workspace monorepo. + * https://reactnative.dev/docs/metro + * + * @type {import('@react-native/metro-config').MetroConfig} + */ +const config = { + watchFolders: [monorepoRoot], + resolver: { + nodeModulesPaths: [ + path.resolve(projectRoot, 'node_modules'), + path.resolve(monorepoRoot, 'node_modules'), + ], + }, +}; + +module.exports = mergeConfig(getDefaultConfig(projectRoot), config); diff --git a/examples/benchmark-native/package.json b/examples/benchmark-native/package.json new file mode 100644 index 000000000000..20e9713f275f --- /dev/null +++ b/examples/benchmark-native/package.json @@ -0,0 +1,42 @@ +{ + "name": "example-benchmark-native", + "version": "0.0.1", + "private": true, + "scripts": { + "android": "react-native run-android --mode=release", + "android:debug": "react-native run-android", + "build:android:release": "bash scripts/build-android-release.sh", + "build:android:release:gradle": "cd android && ./gradlew assembleRelease", + "lint": "yarn g:lint --ignore-pattern android --ignore-pattern artifacts .", + "start": "react-native start", + "test": "jest", + "typecheck": "tsc --noEmit", + "collect": "bash scripts/collect-report.sh", + "matrix": "bash scripts/run-matrix.sh" + }, + "dependencies": { + "@data-client/core": "workspace:*", + "react": "19.2.3", + "react-native": "0.86.2" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@babel/preset-env": "^7.25.3", + "@babel/runtime": "^7.25.0", + "@react-native-community/cli": "20.1.0", + "@react-native-community/cli-platform-android": "20.1.0", + "@react-native/babel-preset": "0.86.2", + "@react-native/metro-config": "0.86.2", + "@react-native/typescript-config": "0.86.2", + "@types/jest": "30.0.0", + "@types/react": "^19.2.0", + "@types/react-test-renderer": "^19.1.0", + "@typescript/native": "npm:typescript@7.0.2", + "jest": "^30.0.0", + "react-test-renderer": "19.2.3", + "typescript": "npm:@typescript/typescript6@6.0.2" + }, + "engines": { + "node": ">= 22.11.0" + } +} diff --git a/examples/benchmark-native/scripts/accept-collected-report.cjs b/examples/benchmark-native/scripts/accept-collected-report.cjs new file mode 100644 index 000000000000..efece63abd56 --- /dev/null +++ b/examples/benchmark-native/scripts/accept-collected-report.cjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +/** + * Reject a pulled report that is not this run, then attach sidecar provenance. + * + * buildId alone is not enough: another scenario from the same APK shares it. + * kind, pattern, count, control, and sample count must match the request. + * + * Usage: + * node accept-collected-report.cjs \ + * + */ +const fs = require('fs'); + +function parseCount(label, raw) { + const n = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isInteger(n) || n < 0) { + throw new Error(`error: invalid ${label}: ${raw}`); + } + return n; +} + +/** + * @param {object} report + * @param {{ buildId: string, candidateKind: string, pattern: string, count: number, control: string, samples: number }} expected + */ +function assertCollectedReport(report, expected) { + const embedded = report.build && report.build.buildId; + if (!embedded || embedded !== expected.buildId) { + throw new Error( + `error: report buildId ${embedded} != sidecar ${expected.buildId}`, + ); + } + const scenario = report.scenarios && report.scenarios[0]; + if (!scenario) { + throw new Error('error: pulled report has no scenario'); + } + const sampleCount = Array.isArray(scenario.samples) + ? scenario.samples.length + : NaN; + const problems = []; + if (scenario.candidateKind !== expected.candidateKind) { + problems.push( + `kind ${scenario.candidateKind} != ${expected.candidateKind}`, + ); + } + if (scenario.pattern !== expected.pattern) { + problems.push(`pattern ${scenario.pattern} != ${expected.pattern}`); + } + if (scenario.count !== expected.count) { + problems.push(`count ${scenario.count} != ${expected.count}`); + } + if (scenario.control !== expected.control) { + problems.push(`control ${scenario.control} != ${expected.control}`); + } + if (sampleCount !== expected.samples) { + problems.push(`samples ${sampleCount} != ${expected.samples}`); + } + if (problems.length) { + throw new Error( + `error: pulled report does not match request: ${problems.join('; ')}`, + ); + } +} + +/** + * @param {object} report + * @param {object} sidecar + * @param {string} installedSha + */ +function attachSidecar(report, sidecar, installedSha) { + report.build = report.build || {}; + report.build.sidecar = { + buildId: sidecar.buildId, + sourceDigest: sidecar.sourceDigest, + apkSha256: sidecar.apkSha256, + apkPath: sidecar.apkPath, + sidecarId: sidecar.sidecarId, + }; + report.build.installedApkSha256 = installedSha; + report.build.apkSizeBytes = sidecar.apkSizeBytes; + return report; +} + +function acceptCollectedReportFile(argv) { + const [ + out, + sidecarPath, + installedSha, + candidateKind, + pattern, + countRaw, + control, + samplesRaw, + ] = argv; + if ( + !out || + !sidecarPath || + !installedSha || + !candidateKind || + !pattern || + countRaw == null || + !control || + samplesRaw == null + ) { + throw new Error( + 'usage: accept-collected-report.cjs ', + ); + } + const report = JSON.parse(fs.readFileSync(out, 'utf8')); + const sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); + assertCollectedReport(report, { + buildId: sidecar.buildId, + candidateKind, + pattern, + count: parseCount('count', countRaw), + control, + samples: parseCount('samples', samplesRaw), + }); + attachSidecar(report, sidecar, installedSha); + fs.writeFileSync(out, JSON.stringify(report, null, 2)); + console.log( + `provenance ok buildId=${sidecar.buildId} sidecarId=${sidecar.sidecarId}`, + ); +} + +module.exports = { + assertCollectedReport, + attachSidecar, + parseCount, +}; + +if (require.main === module) { + try { + acceptCollectedReportFile(process.argv.slice(2)); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + } +} diff --git a/examples/benchmark-native/scripts/build-android-release.sh b/examples/benchmark-native/scripts/build-android-release.sh new file mode 100644 index 000000000000..79f3a8e5b1c8 --- /dev/null +++ b/examples/benchmark-native/scripts/build-android-release.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Authoritative release build: prepare BuildManifest (rebuilds the dist files +# Metro bundles, then hashes them) → Gradle assembleRelease → sidecar. +# Split APKs are unsupported; requires single app-release.apk. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +APK="${ROOT}/android/app/build/outputs/apk/release/app-release.apk" + +echo "== prepare BuildManifest v1 ==" +node "${ROOT}/scripts/build-manifest.cjs" prepare + +echo "== gradle assembleRelease ==" +( + cd "${ROOT}/android" + ./gradlew assembleRelease +) + +if [[ ! -f "${APK}" ]]; then + echo "error: expected single release APK at ${APK}" >&2 + echo "split / multi-APK installs are unsupported" >&2 + exit 1 +fi + +echo "== finalize sidecar ==" +node "${ROOT}/scripts/build-manifest.cjs" finalize "${APK}" + +echo "Release build complete: ${APK}" +echo "Sidecar: ${ROOT}/artifacts/build-sidecar.json" diff --git a/examples/benchmark-native/scripts/build-identity.cjs b/examples/benchmark-native/scripts/build-identity.cjs new file mode 100644 index 000000000000..1153194b4cb0 --- /dev/null +++ b/examples/benchmark-native/scripts/build-identity.cjs @@ -0,0 +1,110 @@ +/** + * Deterministic BuildManifest / sidecar identity helpers (pure). + * + * buildId — source-build identity: sha256 over canonical v1 inputs + * (schemaVersion, gitCommit, gitDirty, sourceDigest). + * Does NOT include createdAt or artifact hashes. + * sidecarId — artifact-aware identity: sha256(buildId || apkSha256). + */ +const crypto = require('crypto'); + +/** + * @param {{ schemaVersion: number, gitCommit: string, gitDirty: boolean, sourceDigest: string }} inputs + * @returns {string} hex sha256 + */ +function computeBuildId(inputs) { + if (inputs.schemaVersion !== 1) { + throw new Error(`unsupported schemaVersion=${inputs.schemaVersion}`); + } + if (typeof inputs.gitCommit !== 'string' || !inputs.gitCommit) { + throw new Error('gitCommit required'); + } + if (typeof inputs.gitDirty !== 'boolean') { + throw new Error('gitDirty must be boolean'); + } + if ( + typeof inputs.sourceDigest !== 'string' || + !/^[0-9a-f]{64}$/.test(inputs.sourceDigest) + ) { + throw new Error('sourceDigest must be 64-char hex sha256'); + } + // Canonical encoding: fixed field order, no timestamps/artifacts. + const payload = [ + String(inputs.schemaVersion), + inputs.gitCommit, + inputs.gitDirty ? '1' : '0', + inputs.sourceDigest, + ].join('\0'); + return crypto.createHash('sha256').update(payload, 'utf8').digest('hex'); +} + +/** + * @param {{ buildId: string, apkSha256: string }} inputs + * @returns {string} hex sha256 + */ +function computeSidecarId(inputs) { + if ( + typeof inputs.buildId !== 'string' || + !/^[0-9a-f]{64}$/.test(inputs.buildId) + ) { + throw new Error('buildId must be 64-char hex sha256'); + } + if ( + typeof inputs.apkSha256 !== 'string' || + !/^[0-9a-f]{64}$/.test(inputs.apkSha256) + ) { + throw new Error('apkSha256 must be 64-char hex sha256'); + } + return crypto + .createHash('sha256') + .update(`${inputs.buildId}\0${inputs.apkSha256}`, 'utf8') + .digest('hex'); +} + +/** + * Recompute buildId from manifest fields and ensure it matches. + * Catches tampered metadata or a forged buildId. + * @param {{ schemaVersion: number, buildId: string, gitCommit: string, gitDirty: boolean, sourceDigest: string }} manifest + */ +function verifyManifestBuildId(manifest) { + const expected = computeBuildId({ + schemaVersion: manifest.schemaVersion, + gitCommit: manifest.gitCommit, + gitDirty: manifest.gitDirty, + sourceDigest: manifest.sourceDigest, + }); + if (manifest.buildId !== expected) { + throw new Error( + `buildId mismatch: embedded=${manifest.buildId} expected=${expected} (tampered metadata or forged buildId)`, + ); + } + return expected; +} + +/** + * Verify sidecar buildId + sidecarId against fields + apkSha256. + * @param {{ schemaVersion: number, buildId: string, gitCommit: string, gitDirty: boolean, sourceDigest: string, apkSha256: string, sidecarId?: string }} sidecar + */ +function verifySidecarIdentity(sidecar) { + verifyManifestBuildId(sidecar); + const expectedSidecarId = computeSidecarId({ + buildId: sidecar.buildId, + apkSha256: sidecar.apkSha256, + }); + if (sidecar.sidecarId == null || sidecar.sidecarId === '') { + throw new Error('sidecarId required (artifact-aware identity)'); + } + if (sidecar.sidecarId !== expectedSidecarId) { + throw new Error( + `sidecarId mismatch: embedded=${sidecar.sidecarId} expected=${expectedSidecarId}`, + ); + } + return expectedSidecarId; +} + +module.exports = { + computeBuildId, + computeSidecarId, + verifyManifestBuildId, + verifySidecarIdentity, +}; diff --git a/examples/benchmark-native/scripts/build-manifest.cjs b/examples/benchmark-native/scripts/build-manifest.cjs new file mode 100644 index 000000000000..375603843981 --- /dev/null +++ b/examples/benchmark-native/scripts/build-manifest.cjs @@ -0,0 +1,495 @@ +#!/usr/bin/env node +/** + * BuildManifest v1 prepare / finalize for release-Hermes Android GC bench. + * + * prepare — write android/app/src/main/assets/build-manifest.json + * buildId = deterministic sha256(schemaVersion, gitCommit, gitDirty, sourceDigest) + * finalize — verify embedded buildId, write artifacts/build-sidecar.json with + * apkSha256 (artifact identity) + sidecarId (buildId∥apkSha256) + * digest — print current sourceDigest (for stale checks) + * verify — verify manifest and/or sidecar identity + * + * Inputs: sorted paths under this app (tracked+untracked contents) plus the + * package files the release Metro bundle actually resolves (@data-client/* + * via package exports key order: import → node.mjs, require → dist/) and + * examples/gc-shared, excluding build/node_modules/artifacts/.jdk/generated. + * prepare rebuilds those package bundles (build:bundle) before hashing. + */ +const { execSync } = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const { + computeBuildId, + computeSidecarId, + verifyManifestBuildId, + verifySidecarIdentity, +} = require('./build-identity.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const REPO = path.resolve(ROOT, '../..'); +const ASSET_DIR = path.join(ROOT, 'android/app/src/main/assets'); +const MANIFEST_PATH = path.join(ASSET_DIR, 'build-manifest.json'); +const SIDECAR_PATH = path.join(ROOT, 'artifacts/build-sidecar.json'); +const DEFAULT_APK = path.join( + ROOT, + 'android/app/build/outputs/apk/release/app-release.apk', +); + +const IGNORE_DIR_NAMES = new Set([ + 'node_modules', + 'build', + 'artifacts', + 'coverage', + 'Pods', +]); + +function shouldSkipDir(name) { + return IGNORE_DIR_NAMES.has(name) || name.startsWith('.'); +} + +function walkFiles(dir, out) { + if (!fs.existsSync(dir)) return; + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + if (ent.name === 'build-manifest.json') continue; // generated + const full = path.join(dir, ent.name); + if (ent.isDirectory()) { + if (shouldSkipDir(ent.name)) continue; + walkFiles(full, out); + } else if (ent.isFile()) { + out.push(full); + } + } +} + +const { resolve: metroResolve } = require('metro-resolver'); + +let metroResolverConfig; +function loadMetroResolverConfig() { + if (!metroResolverConfig) { + metroResolverConfig = require(path.join(ROOT, 'metro.config.js')).resolver; + } + return metroResolverConfig; +} + +function realpathOrSelf(filePath) { + try { + return fs.realpathSync(filePath); + } catch { + return filePath; + } +} + +function fileSystemLookup(filePath) { + try { + const stat = fs.lstatSync(filePath); + return { + exists: true, + type: stat.isDirectory() ? 'd' : 'f', + realPath: realpathOrSelf(filePath), + }; + } catch { + return { exists: false }; + } +} + +function getPackageForModule(abs) { + let dir = abs; + try { + if (!fs.statSync(abs).isDirectory()) dir = path.dirname(abs); + } catch { + dir = path.dirname(abs); + } + while (true) { + const pkgPath = path.join(dir, 'package.json'); + if (fs.existsSync(pkgPath)) { + const rel = path.relative(dir, abs); + return { + rootPath: dir, + packageJson: JSON.parse(fs.readFileSync(pkgPath, 'utf8')), + packageRelativePath: rel === '' ? '' : rel, + }; + } + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** + * Same resolver context the release bundle uses (metro.config.js + RN defaults). + * isESMImport selects the `import` condition; otherwise Metro asserts `require`. + * Both are tried before `react-native` because matching follows the package's + * own exports key order. + */ +function metroResolveContext(originModulePath, isESMImport) { + const resolver = loadMetroResolverConfig(); + return { + allowHaste: false, + assetExts: new Set(resolver.assetExts || []), + dev: false, + disableHierarchicalLookup: !!resolver.disableHierarchicalLookup, + doesFileExist: filePath => { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } + }, + extraNodeModules: resolver.extraNodeModules || {}, + fileSystemLookup, + getPackage: pkgPath => JSON.parse(fs.readFileSync(pkgPath, 'utf8')), + getPackageForModule, + isESMImport, + mainFields: resolver.resolverMainFields, + nodeModulesPaths: resolver.nodeModulesPaths, + originModulePath, + preferNativePlatform: true, + resolveAsset: () => null, + resolveRequest: resolver.resolveRequest || null, + sourceExts: resolver.sourceExts, + unstable_conditionNames: resolver.unstable_conditionNames, + unstable_conditionsByPlatform: resolver.unstable_conditionsByPlatform, + unstable_enablePackageExports: resolver.unstable_enablePackageExports, + unstable_logWarning: () => {}, + }; +} + +function resolveMetroFile(originModulePath, specifier, isESMImport) { + const resolved = metroResolve( + metroResolveContext(originModulePath, isESMImport), + specifier, + 'android', + ); + if (!resolved || resolved.type !== 'sourceFile' || !resolved.filePath) { + throw new Error( + `Metro did not resolve ${specifier} from ${originModulePath}`, + ); + } + return resolved.filePath; +} + +function staticDependencies(code) { + const deps = []; + const seen = new Set(); + const add = (specifier, isESMImport) => { + if (!specifier.startsWith('.') && !specifier.startsWith('@data-client/')) { + return; + } + const key = `${isESMImport ? 'esm' : 'cjs'}:${specifier}`; + if (seen.has(key)) return; + seen.add(key); + deps.push({ specifier, isESMImport }); + }; + const esmFrom = + /(?:^|[;\n])\s*(?:import|export)\s+(?!type\b)[\s\S]*?\sfrom\s*['"]([^'"]+)['"]/g; + const esmSide = /(?:^|[;\n])\s*import\s+['"]([^'"]+)['"]/g; + const cjs = /require\(\s*['"]([^'"]+)['"]\s*\)/g; + let match; + while ((match = esmFrom.exec(code))) add(match[1], true); + while ((match = esmSide.exec(code))) add(match[1], true); + while ((match = cjs.exec(code))) add(match[1], false); + return deps; +} + +function shouldFollowResolved(filePath) { + const rel = path.relative(REPO, filePath).split(path.sep).join('/'); + if (rel.startsWith('..')) return false; + return ( + rel.startsWith('examples/benchmark-native/') || + rel.startsWith('examples/gc-shared/') || + rel.startsWith('packages/') + ); +} + +/** + * Package files reachable from the release entry the same way Metro resolves + * them (android, dev false). This is the closure in the release bundle, not + * the `react-native` exports field (lib/) and not unused dist siblings. + */ +function metroBundledPackageFiles() { + const entry = path.join(ROOT, 'index.js'); + const queue = [entry]; + const seen = new Set(); + const packageFiles = []; + while (queue.length) { + const file = queue.pop(); + const real = realpathOrSelf(file); + if (seen.has(real)) continue; + seen.add(real); + const rel = path.relative(REPO, real).split(path.sep).join('/'); + if (rel.startsWith('packages/')) packageFiles.push(real); + if (!shouldFollowResolved(real)) continue; + let code; + try { + code = fs.readFileSync(real, 'utf8'); + } catch { + continue; + } + for (const dep of staticDependencies(code)) { + let next; + try { + next = resolveMetroFile(real, dep.specifier, dep.isESMImport); + } catch (err) { + throw new Error( + `Metro resolve failed for ${dep.specifier} from ${path.relative(REPO, real)}: ${err.message}`, + ); + } + queue.push(next); + } + } + return packageFiles.sort((a, b) => a.localeCompare(b)); +} + +function dataClientPackageName(filePath) { + const rel = path.relative(REPO, filePath).split(path.sep); + if (rel[0] !== 'packages' || !rel[1]) return null; + return rel[1]; +} + +function runPackageBuildBundle(packageDirName) { + const pkgPath = path.join(REPO, 'packages', packageDirName, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (!pkg.scripts || !pkg.scripts['build:bundle']) { + throw new Error(`${pkg.name} has no build:bundle script`); + } + console.log( + `== build:bundle ${pkg.name} (file the release Metro bundle resolves) ==`, + ); + execSync(`yarn workspace ${pkg.name} run build:bundle`, { + cwd: REPO, + stdio: 'inherit', + }); +} + +/** + * Rebuild dist outputs Metro will bundle, then return that file list. + * Direct @data-client imports are built first so node.mjs → dist can resolve; + * transitive packages (normalizr, required from core's dist) are built next. + */ +function ensureMetroBundlesBuilt() { + const built = new Set(); + const build = name => { + if (built.has(name)) return; + built.add(name); + runPackageBuildBundle(name); + }; + for (let attempt = 0; attempt < 6; attempt++) { + let files; + try { + files = metroBundledPackageFiles(); + } catch (err) { + const missing = String(err.message).match(/packages\/([^/]+)\/dist/); + if (missing && !built.has(missing[1])) { + build(missing[1]); + continue; + } + throw err; + } + const pending = []; + for (const file of files) { + const name = dataClientPackageName(file); + const rel = path.relative(REPO, file).split(path.sep).join('/'); + if (name && rel.includes('/dist/') && !built.has(name)) { + pending.push(name); + } + } + if (!pending.length) return files; + for (const name of pending) build(name); + } + throw new Error('could not build the package bundles Metro resolves'); +} + +function collectInputFiles() { + const files = []; + walkFiles(ROOT, files); + for (const file of metroBundledPackageFiles()) files.push(file); + walkFiles(path.join(REPO, 'examples/gc-shared'), files); + return files.map(f => path.resolve(f)).sort((a, b) => a.localeCompare(b)); +} + +function sha256File(filePath) { + const h = crypto.createHash('sha256'); + h.update(fs.readFileSync(filePath)); + return h.digest('hex'); +} + +function hashInputFiles(files) { + const h = crypto.createHash('sha256'); + const sorted = files + .map(f => path.resolve(f)) + .sort((a, b) => a.localeCompare(b)); + for (const file of sorted) { + const rel = path.relative(REPO, file).split(path.sep).join('/'); + h.update(rel); + h.update('\0'); + h.update(fs.readFileSync(file)); + h.update('\0'); + } + return h.digest('hex'); +} + +function sourceDigest() { + return hashInputFiles(collectInputFiles()); +} + +function gitMeta() { + try { + const commit = execSync('git rev-parse HEAD', { + cwd: REPO, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + const dirty = + execSync('git status --porcelain', { + cwd: REPO, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim().length > 0; + return { gitCommit: commit, gitDirty: dirty }; + } catch { + return { gitCommit: 'unknown', gitDirty: true }; + } +} + +function prepare() { + ensureMetroBundlesBuilt(); + const digest = sourceDigest(); + const { gitCommit, gitDirty } = gitMeta(); + const schemaVersion = 1; + const buildId = computeBuildId({ + schemaVersion, + gitCommit, + gitDirty, + sourceDigest: digest, + }); + const manifest = { + schemaVersion, + buildId, + gitCommit, + gitDirty, + sourceDigest: digest, + createdAt: new Date().toISOString(), + }; + fs.mkdirSync(ASSET_DIR, { recursive: true }); + fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n'); + console.log(`Wrote ${MANIFEST_PATH}`); + console.log(`buildId=${buildId} (source-build identity)`); + console.log(`sourceDigest=${digest}`); + return manifest; +} + +function finalize(apkPath = DEFAULT_APK) { + if (!fs.existsSync(MANIFEST_PATH)) { + throw new Error('missing build-manifest.json — run prepare first'); + } + if (!fs.existsSync(apkPath)) { + throw new Error(`APK not found: ${apkPath}`); + } + const splitsDir = path.join( + ROOT, + 'android/app/build/outputs/apk/release/splits', + ); + if (fs.existsSync(splitsDir)) { + throw new Error( + 'split APKs unsupported; use a single release APK (assembleRelease app-release.apk)', + ); + } + if (path.basename(apkPath) !== 'app-release.apk') { + console.warn( + `warning: expected app-release.apk, got ${path.basename(apkPath)}`, + ); + } + + const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); + verifyManifestBuildId(manifest); + + // Stale prepare: source tree changed after prepare but before finalize. + const currentDigest = sourceDigest(); + if (currentDigest !== manifest.sourceDigest) { + throw new Error( + `stale BuildManifest: sourceDigest ${manifest.sourceDigest} != current ${currentDigest}; re-run prepare`, + ); + } + + const apkSha256 = sha256File(apkPath); + const apkSizeBytes = fs.statSync(apkPath).size; + const sidecarId = computeSidecarId({ + buildId: manifest.buildId, + apkSha256, + }); + const sidecar = { + schemaVersion: 1, + buildId: manifest.buildId, + sourceDigest: manifest.sourceDigest, + gitCommit: manifest.gitCommit, + gitDirty: manifest.gitDirty, + apkPath: path.resolve(apkPath), + apkSha256, + apkSizeBytes, + sidecarId, + builtAt: new Date().toISOString(), + }; + fs.mkdirSync(path.dirname(SIDECAR_PATH), { recursive: true }); + fs.writeFileSync(SIDECAR_PATH, JSON.stringify(sidecar, null, 2) + '\n'); + console.log(`Wrote ${SIDECAR_PATH}`); + console.log(`apkSha256=${apkSha256} (artifact identity)`); + console.log(`sidecarId=${sidecarId}`); + return sidecar; +} + +function verify() { + if (fs.existsSync(MANIFEST_PATH)) { + const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); + verifyManifestBuildId(manifest); + console.log(`manifest buildId ok: ${manifest.buildId}`); + } else { + console.log('no manifest present'); + } + if (fs.existsSync(SIDECAR_PATH)) { + const sidecar = JSON.parse(fs.readFileSync(SIDECAR_PATH, 'utf8')); + verifySidecarIdentity(sidecar); + console.log(`sidecar identity ok: sidecarId=${sidecar.sidecarId}`); + } else { + console.log('no sidecar present'); + } +} + +module.exports = { + collectInputFiles, + ensureMetroBundlesBuilt, + hashInputFiles, + metroBundledPackageFiles, + resolveMetroFile, + sourceDigest, +}; + +function main() { + const cmd = process.argv[2] || 'digest'; + if (cmd === 'prepare') { + prepare(); + } else if (cmd === 'finalize') { + finalize(process.argv[3] || DEFAULT_APK); + } else if (cmd === 'digest') { + console.log(sourceDigest()); + } else if (cmd === 'hash') { + const filePath = process.argv[3]; + if (!filePath) { + console.error('usage: build-manifest.cjs hash '); + process.exit(1); + } + console.log(sha256File(filePath)); + } else if (cmd === 'verify') { + verify(); + } else { + console.error( + 'usage: build-manifest.cjs prepare|finalize [apk]|digest|hash |verify', + ); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} diff --git a/examples/benchmark-native/scripts/collect-report.sh b/examples/benchmark-native/scripts/collect-report.sh new file mode 100644 index 000000000000..4e0bb16958e9 --- /dev/null +++ b/examples/benchmark-native/scripts/collect-report.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +# Install/start a single release APK, pass scenario axes, pull report, verify provenance. +# +# Requires exactly one adb device unless ANDROID_SERIAL is set and valid. +# Split APKs unsupported. Sidecar (artifacts/build-sidecar.json) is authority — +# never the live checkout. Intent label is optional; commit is not authority. +# +# Report I/O: the app writes app-specific external storage (externalFilesDir) +# and, on API 29+, a Downloads mirror. Collect uses adb pull / exec-out cat — +# never run-as (release is not debuggable). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PROTOCOL_CLI="${ROOT}/../gc-shared/protocol-cli.js" + +APP_ID="${APP_ID:-com.dataclient.benchmarknative}" +ACTIVITY="${ACTIVITY:-${APP_ID}/.MainActivity}" +APK="${APK:-${ROOT}/android/app/build/outputs/apk/release/app-release.apk}" +SIDECAR="${SIDECAR:-${ROOT}/artifacts/build-sidecar.json}" +OUT="${OUT:-${ROOT}/artifacts/gc-report.json}" +TIMEOUT_SEC="${TIMEOUT_SEC:-600}" + +CANDIDATE_KIND="${CANDIDATE_KIND:-entity}" +PATTERN="${PATTERN:-unique}" +COUNT="${COUNT:-1000}" +CONTROL="${CONTROL:-gc}" +SAMPLES="${SAMPLES:-1}" +LABEL="${LABEL:-}" +INSTALL="${INSTALL:-1}" + +# Validate axes before any install/device work (shared GC protocol). +node "${PROTOCOL_CLI}" validate "${CANDIDATE_KIND}" "${PATTERN}" "${COUNT}" "${CONTROL}" "${SAMPLES}" + +mkdir -p "$(dirname "${OUT}")" + +if ! command -v adb >/dev/null 2>&1; then + echo "error: adb not found on PATH" >&2 + exit 1 +fi + +if [[ ! -f "${SIDECAR}" ]]; then + echo "error: missing sidecar ${SIDECAR}" >&2 + echo "Build with: yarn workspace example-benchmark-native build:android:release" >&2 + exit 1 +fi + +if [[ ! -f "${APK}" ]]; then + echo "error: APK not found at ${APK}" >&2 + echo "Split APKs are unsupported; need single app-release.apk" >&2 + exit 1 +fi + +if [[ "$(basename "${APK}")" != "app-release.apk" ]]; then + echo "error: only single release APK app-release.apk is supported (got $(basename "${APK}"))" >&2 + exit 1 +fi + +# --- device selection --- +# Bash 3.2 (stock macOS) has no mapfile. Keep an empty array when adb +# lists no eligible devices. +_DEVICES=() +while IFS= read -r _device; do + [[ -n "${_device}" ]] || continue + _DEVICES+=("${_device}") +done < <(adb devices | awk 'NR>1 && $2=="device" {print $1}') +if [[ -n "${ANDROID_SERIAL:-}" ]]; then + FOUND=0 + for d in "${_DEVICES[@]:-}"; do + if [[ "${d}" == "${ANDROID_SERIAL}" ]]; then + FOUND=1 + break + fi + done + if [[ "${FOUND}" != "1" ]]; then + echo "error: ANDROID_SERIAL=${ANDROID_SERIAL} is not an eligible 'device'" >&2 + adb devices >&2 || true + exit 1 + fi + SERIAL="${ANDROID_SERIAL}" +else + if [[ "${#_DEVICES[@]}" -ne 1 ]]; then + echo "error: need exactly one eligible adb device (found ${#_DEVICES[@]}); set ANDROID_SERIAL" >&2 + adb devices >&2 || true + exit 1 + fi + SERIAL="${_DEVICES[0]}" +fi +ADB=(adb -s "${SERIAL}") +echo "Using device ${SERIAL}" + +# adb-visible report paths. Release APKs are not debuggable, so run-as cannot +# read private filesDir. Prefer the Downloads mirror on Android 10+ user builds +# (shell often cannot read /Android/data//). +REPORT_NAME="gc-report.json" +PUBLIC_REPORT_NAME="dataclient-gc-report.json" +DEVICE_REPORT_APP="/sdcard/Android/data/${APP_ID}/files/${REPORT_NAME}" +DEVICE_REPORT_APP_EMU="/storage/emulated/0/Android/data/${APP_ID}/files/${REPORT_NAME}" +DEVICE_REPORT_PUBLIC="/sdcard/Download/${PUBLIC_REPORT_NAME}" +DEVICE_REPORT_PUBLIC_EMU="/storage/emulated/0/Download/${PUBLIC_REPORT_NAME}" +# Pull order: public Downloads first, then app-scoped externalFilesDir. +DEVICE_REPORTS=( + "${DEVICE_REPORT_PUBLIC}" + "${DEVICE_REPORT_PUBLIC_EMU}" + "${DEVICE_REPORT_APP}" + "${DEVICE_REPORT_APP_EMU}" +) + +clear_device_reports() { + "${ADB[@]}" shell "rm -f $(printf "'%s' " "${DEVICE_REPORTS[@]}")" >/dev/null 2>&1 || true + # Downloads mirror is MediaStore-owned; filesystem rm can leave the row. + "${ADB[@]}" shell "content delete --uri content://media/external/downloads --where \"_display_name='${PUBLIC_REPORT_NAME}'\"" >/dev/null 2>&1 || true +} + +device_report_ready() { + local p + for p in "${DEVICE_REPORTS[@]}"; do + if "${ADB[@]}" shell "test -s '${p}'" >/dev/null 2>&1; then + return 0 + fi + done + return 1 +} + +# Fail closed: a leftover Downloads/externalFilesDir file must not count as this run. +assert_device_reports_absent() { + if device_report_ready; then + echo "error: leftover report still on device after clear (refusing to collect a stale file)" >&2 + echo "remove ${DEVICE_REPORT_PUBLIC} and ${DEVICE_REPORT_APP} then retry" >&2 + exit 1 + fi +} + +extract_logcat_report_path() { + local line + line="$("${ADB[@]}" logcat -d -s BenchNative:I | tr -d '\r' | grep 'REPORT_READY' | tail -n 1 || true)" + if [[ -z "${line}" ]]; then + return 1 + fi + if [[ "${line}" =~ pull=([^[:space:]]+) ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + return 0 + fi + if [[ "${line}" =~ path=([^[:space:]]+) ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + return 0 + fi + return 1 +} + +pull_device_report() { + local candidates=() + local from_log src + from_log="$(extract_logcat_report_path || true)" + if [[ -n "${from_log}" ]]; then + candidates+=("${from_log}") + fi + candidates+=("${DEVICE_REPORTS[@]}") + for src in "${candidates[@]}"; do + rm -f "${OUT}" + if "${ADB[@]}" pull "${src}" "${OUT}" >/dev/null 2>&1 && [[ -s "${OUT}" ]]; then + echo "Pulled ${src} → ${OUT}" + return 0 + fi + if "${ADB[@]}" exec-out cat "${src}" > "${OUT}" 2>/dev/null && [[ -s "${OUT}" ]]; then + echo "Read ${src} → ${OUT}" + return 0 + fi + done + return 1 +} + +# --- provenance: local APK must match sidecar (authority) --- +node -e ' +const fs=require("fs"); +const { verifySidecarIdentity } = require(process.argv[2]); +const sidecar=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); +if (sidecar.schemaVersion!==1) { console.error("bad sidecar schema"); process.exit(2); } +verifySidecarIdentity(sidecar); +console.log("sidecar identity verified buildId="+sidecar.buildId+" sidecarId="+sidecar.sidecarId); +' "${SIDECAR}" "${ROOT}/scripts/build-identity.cjs" + +read -r SIDECAR_DIGEST SIDECAR_APK_SHA < <(node -e 'const s=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(s.sourceDigest,s.apkSha256)' "${SIDECAR}") + +LOCAL_APK_SHA="$(node "${ROOT}/scripts/build-manifest.cjs" hash "${APK}")" +if [[ "${LOCAL_APK_SHA}" != "${SIDECAR_APK_SHA}" ]]; then + echo "error: local APK sha256 ${LOCAL_APK_SHA} != sidecar ${SIDECAR_APK_SHA} (stale artifact)" >&2 + exit 1 +fi + +CURRENT_DIGEST="$(node "${ROOT}/scripts/build-manifest.cjs" digest)" +if [[ "${CURRENT_DIGEST}" != "${SIDECAR_DIGEST}" ]]; then + echo "error: current sourceDigest ${CURRENT_DIGEST} != sidecar ${SIDECAR_DIGEST}" >&2 + echo "Rebuild with yarn workspace example-benchmark-native build:android:release" >&2 + exit 1 +fi + +if [[ "${INSTALL}" == "1" ]]; then + echo "Installing single APK ${APK}…" + "${ADB[@]}" install -r "${APK}" +fi + +# Hash the installed base APK (stream via adb exec-out / pull path). +TMP_INSTALLED="$(mktemp)" +cleanup() { rm -f "${TMP_INSTALLED}"; } +trap cleanup EXIT + +# Prefer `pm path` → pull the single base APK. +PM_PATH_OUT="$("${ADB[@]}" shell pm path "${APP_ID}" | tr -d '\r' || true)" +INSTALLED_PATH="$(awk -F: '/^package:/{print $2; exit}' <<<"${PM_PATH_OUT}")" +if [[ -z "${INSTALLED_PATH}" ]]; then + echo "error: could not resolve installed package path for ${APP_ID}" >&2 + exit 1 +fi +PATH_COUNT="$(grep -c '^package:' <<<"${PM_PATH_OUT}" || true)" +if [[ "${PATH_COUNT}" -ne 1 ]]; then + echo "error: expected exactly one installed APK path (split APKs unsupported); got ${PATH_COUNT}" >&2 + printf '%s\n' "${PM_PATH_OUT}" >&2 + exit 1 +fi + +"${ADB[@]}" pull "${INSTALLED_PATH}" "${TMP_INSTALLED}" >/dev/null +INSTALLED_SHA="$(node "${ROOT}/scripts/build-manifest.cjs" hash "${TMP_INSTALLED}")" +if [[ "${INSTALLED_SHA}" != "${SIDECAR_APK_SHA}" ]]; then + echo "error: installed APK sha256 ${INSTALLED_SHA} != sidecar ${SIDECAR_APK_SHA}" >&2 + exit 1 +fi +echo "installedApkSha256=${INSTALLED_SHA}" + +# Stop a previous run before deleting its report. A still-running app can +# write after the absence check (INSTALL=0, or a retry after timeout). +echo "Stopping ${APP_ID} before clearing leftover reports…" +"${ADB[@]}" shell am force-stop "${APP_ID}" + +# Clear previous report (externalFilesDir + Downloads mirror; no run-as). +# Verify gone so a leftover cannot complete wait for the wrong scenario. +clear_device_reports +assert_device_reports_absent + +echo "Starting ${ACTIVITY} with axes ${CANDIDATE_KIND}/${PATTERN}/${COUNT}/interaction/${CONTROL}…" +"${ADB[@]}" logcat -c || true + +START_ARGS=( + am start -n "${ACTIVITY}" + --ez autoRun true + --es candidateKind "${CANDIDATE_KIND}" + --es pattern "${PATTERN}" + --ei count "${COUNT}" + --es control "${CONTROL}" + --ei samples "${SAMPLES}" +) +if [[ -n "${LABEL}" ]]; then + START_ARGS+=(--es label "${LABEL}") +fi +"${ADB[@]}" shell "${START_ARGS[@]}" + +echo "Waiting for REPORT_READY (timeout ${TIMEOUT_SEC}s)…" +deadline=$((SECONDS + TIMEOUT_SEC)) +found=0 +while (( SECONDS < deadline )); do + if "${ADB[@]}" logcat -d -s BenchNative:I | grep -q 'REPORT_READY'; then + found=1 + break + fi + if device_report_ready; then + found=1 + break + fi + sleep 2 +done + +if [[ "${found}" != "1" ]]; then + echo "error: timed out waiting for report" >&2 + "${ADB[@]}" logcat -d -s BenchNative:I ReactNativeJS:E AndroidRuntime:E | tail -n 80 >&2 || true + exit 1 +fi + +if ! pull_device_report; then + echo "error: REPORT_READY seen but adb could not pull the report" >&2 + echo "tried Downloads mirror and Android/data/${APP_ID}/files/${REPORT_NAME}" >&2 + "${ADB[@]}" logcat -d -s BenchNative:I | grep 'REPORT_READY' | tail -n 5 >&2 || true + exit 1 +fi +echo "Wrote ${OUT}" + +# Reject another scenario from this APK (buildId alone is not enough), then +# attach sidecar provenance + installed hash. +node "${ROOT}/scripts/accept-collected-report.cjs" \ + "${OUT}" "${SIDECAR}" "${INSTALLED_SHA}" \ + "${CANDIDATE_KIND}" "${PATTERN}" "${COUNT}" "${CONTROL}" "${SAMPLES}" + +echo "Done." diff --git a/examples/benchmark-native/scripts/run-matrix.sh b/examples/benchmark-native/scripts/run-matrix.sh new file mode 100644 index 000000000000..2e2d340b59c4 --- /dev/null +++ b/examples/benchmark-native/scripts/run-matrix.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Matrix runner over the full canonical android interaction axes. +# +# Scenarios come from the shared GC protocol (examples/gc-shared), in device +# matrix order: +# entity|endpoint|mixed × unique × {1000,10000,100000} × {gc,no-gc} +# entity × duplicate × {1000,10000,100000} × {gc,no-gc} +# +# Safety: count=100000 is skipped unless FULL=1 or an explicit filter selects it. +# +# Usage: +# bash scripts/run-matrix.sh +# bash scripts/run-matrix.sh entity/unique/1000 # 1000 only, not 10k/100k +# bash scripts/run-matrix.sh entity/unique/100000 # 100k via filter +# bash scripts/run-matrix.sh /100000/ # only 100k; no FULL=1 +# bash scripts/run-matrix.sh interaction/no-gc # every no-gc control row +# bash scripts/run-matrix.sh ^android/mixed/ # id prefix +# FULL=1 SAMPLES=5 bash scripts/run-matrix.sh # entire matrix incl. 100k +# +# Filters match slash-bounded contiguous segments of the stable id +# android/{kind}/{pattern}/{count}/interaction/{control} (or ^prefix), not raw +# substrings. kind/pattern/count/control is not contiguous (interaction sits +# between count and control); use e.g. unique/1000/interaction/gc. +# DRY_RUN=1 prints matched specs (kind/pattern/count/control) and does not +# install or collect. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PROTOCOL_CLI="${ROOT}/../gc-shared/protocol-cli.js" + +FILTER="${1:-}" +OUT_DIR="${OUT_DIR:-${ROOT}/artifacts/matrix}" +SAMPLES="${SAMPLES:-1}" +INSTALL_FIRST="${INSTALL_FIRST:-1}" +FULL="${FULL:-0}" + +mkdir -p "${OUT_DIR}" + +# Stable ids already filtered by the shared protocol. +SCENARIO_IDS=() +while IFS= read -r id; do + SCENARIO_IDS+=("${id}") +done < <(node "${PROTOCOL_CLI}" list android "${FILTER}") + +install_flag=1 +if [[ "${INSTALL_FIRST}" != "1" ]]; then + install_flag=0 +fi + +ran=0 +skipped_100k=0 +for id in ${SCENARIO_IDS[@]+"${SCENARIO_IDS[@]}"}; do + IFS='/' read -r _platform kind pattern count _mode control <<<"${id}" + spec="${kind}/${pattern}/${count}/${control}" + + # 100k safety: require FULL=1, or an explicit filter that selected this row. + if [[ "${count}" == "100000" && "${FULL}" != "1" && -z "${FILTER}" ]]; then + skipped_100k=$((skipped_100k + 1)) + continue + fi + + if [[ "${DRY_RUN:-0}" == "1" ]]; then + echo "${spec}" + ran=$((ran + 1)) + continue + fi + + node "${PROTOCOL_CLI}" validate "${kind}" "${pattern}" "${count}" "${control}" "${SAMPLES}" + + echo "=== matrix ${kind}/${pattern}/${count}/interaction/${control} ===" + OUT="${OUT_DIR}/android-${kind}-${pattern}-${count}-interaction-${control}.json" \ + CANDIDATE_KIND="${kind}" \ + PATTERN="${pattern}" \ + COUNT="${count}" \ + CONTROL="${control}" \ + SAMPLES="${SAMPLES}" \ + INSTALL="${install_flag}" \ + bash "${ROOT}/scripts/collect-report.sh" + install_flag=0 + ran=$((ran + 1)) +done + +if [[ "${ran}" -eq 0 ]]; then + echo "error: no scenarios matched (filter=${FILTER:-} FULL=${FULL})" >&2 + echo "hint: FULL=1 includes all 100k rows; or filter e.g. entity/unique/100000" >&2 + exit 1 +fi + +if [[ "${skipped_100k}" -gt 0 ]]; then + echo "note: skipped ${skipped_100k} × 100k scenarios (set FULL=1 or pass a 100k filter)" +fi + +echo "Matrix complete (${ran} scenarios) → ${OUT_DIR}" diff --git a/examples/benchmark-native/src/BenchNative.ts b/examples/benchmark-native/src/BenchNative.ts new file mode 100644 index 000000000000..6bc0f32df3da --- /dev/null +++ b/examples/benchmark-native/src/BenchNative.ts @@ -0,0 +1,81 @@ +import { NativeModules, Platform } from 'react-native'; + +import type { UiCaptureSource } from './types'; + +export interface MemorySnapshot { + totalPssKb: number; + totalPrivateDirtyKb?: number; + rssKb?: number; +} + +export interface UiFrameCaptureResult { + source: UiCaptureSource; + frameCount: number; + maxFrameDurationMs: number; + totalFrameDurationMs: number; + missedFrames: number; + /** Sum of FrameMetrics dropCountSinceLastInvocation. Zero for Choreographer. */ + droppedFrameMetrics: number; + /** True when droppedFrameMetrics is non-zero. */ + frameMetricsDropped: boolean; + refreshPeriodMs: number; + refreshRateHz: number; + wasCapturing?: boolean; +} + +export interface NativeEnvironment { + apiLevel: number; + release: string; + manufacturer: string; + model: string; + device: string; + brand: string; + buildType: string; + applicationId: string; + hermesEnabled: boolean; + hermesRuntimeProperties?: Record; + refreshRateHz: number; + refreshPeriodMs: number; +} + +export interface NativeLaunchConfig { + autoRun: boolean; + candidateKind?: string; + pattern?: string; + count?: number; + control?: string; + samples?: number; + label?: string; +} + +interface BenchNativeNativeModule { + getLaunchConfig(): Promise; + getBuildManifest(): Promise<{ json: string }>; + getEnvironment(): Promise; + getMemorySnapshot(): Promise; + startUiFrameCapture(): Promise<{ started: boolean; source: string }>; + stopUiFrameCapture(): Promise; + writeReport(json: string): Promise<{ path: string; pullPath?: string }>; +} + +const LINKING_ERROR = `BenchNative native module is not linked. Rebuild the Android app.`; + +const BenchNative: BenchNativeNativeModule = + Platform.OS === 'android' && NativeModules.BenchNative != null + ? NativeModules.BenchNative + : new Proxy({} as BenchNativeNativeModule, { + get() { + throw new Error(LINKING_ERROR); + }, + }); + +export default BenchNative; + +/** Optional JS heap when Hermes/RN exposes performance.memory. */ +export function readJsHeapBytes(): number | undefined { + if (typeof performance === 'undefined') return undefined; + const mem = performance.memory; + if (mem == null) return undefined; + const used = mem.usedJSHeapSize; + return typeof used === 'number' && Number.isFinite(used) ? used : undefined; +} diff --git a/examples/benchmark-native/src/frames.ts b/examples/benchmark-native/src/frames.ts new file mode 100644 index 000000000000..a2cf95f4f55d --- /dev/null +++ b/examples/benchmark-native/src/frames.ts @@ -0,0 +1,137 @@ +/** + * JS frame / responsiveness helpers (rAF + timers; not pointer latency). + * + * Native UI missed-frame math differs by capture source: + * - FrameMetrics TOTAL_DURATION is a *duration* → ceil(duration/period) − 1 + * - Choreographer deltas are *intervals* → round(interval/period) − 1 (same as JS rAF) + */ +import type { UiFrameCaptureResult } from './BenchNative'; +import { frameIntervalsFromTimestamps } from '../../gc-shared/frames.js'; + +export { + computeMaxInputDelayMs, + frameIntervalsFromTimestamps, + measureDisplayPeriodMs, + median, +} from '../../gc-shared/frames.js'; + +/** + * FrameMetrics TOTAL_DURATION → excess missed frames. + * Conservative: ceil so a duration just over one period counts as a miss. + */ +export function missedFramesFromDurationMs( + durationMs: number, + refreshPeriodMs: number, +): number { + if (!(refreshPeriodMs > 0) || !(durationMs > 0)) return 0; + return Math.max(0, Math.ceil(durationMs / refreshPeriodMs) - 1); +} + +/** + * Choreographer / JS rAF *interval* → excess missed frames (nearest-period). + */ +export function missedFramesFromIntervalMs( + intervalMs: number, + refreshPeriodMs: number, +): number { + if (!(refreshPeriodMs > 0) || !(intervalMs > 0)) return 0; + return Math.max(0, Math.round(intervalMs / refreshPeriodMs) - 1); +} + +/** + * Sum interval-style misses (JS rAF / Choreographer). + */ +export function excessMissedFramesFromIntervals( + intervalsMs: number[], + displayPeriodMs: number, +): number { + let missed = 0; + for (const interval of intervalsMs) { + missed += missedFramesFromIntervalMs(interval, displayPeriodMs); + } + return missed; +} + +export function missedFramesFromTimestamps( + timestampsMs: number[], + displayPeriodMs: number, +): number { + return excessMissedFramesFromIntervals( + frameIntervalsFromTimestamps(timestampsMs), + displayPeriodMs, + ); +} + +/** + * FrameMetrics dropCountSinceLastInvocation is a loss count, not jank. + * The flag is set when the summed count is non-zero. Callers must not add + * the count to missedFrames or uiMissedFrames. + */ +export function withFrameMetricsLoss< + T extends { missedFrames: number; uiMissedFrames: number }, +>( + sample: T, + droppedFrameMetrics: number, +): T & { + uiDroppedFrameMetrics: number; + uiFrameMetricsDropped: boolean; +} { + if (!Number.isInteger(droppedFrameMetrics) || droppedFrameMetrics < 0) { + throw new Error( + `invalid FrameMetrics drop count: ${String(droppedFrameMetrics)}`, + ); + } + return { + ...sample, + missedFrames: sample.missedFrames, + uiMissedFrames: sample.uiMissedFrames, + uiDroppedFrameMetrics: droppedFrameMetrics, + uiFrameMetricsDropped: droppedFrameMetrics > 0, + }; +} + +/** + * Reject invalid refresh period or zero/insufficient native frames. + * Missed-frame math must match capture source semantics. + */ +export function validateUiFrameCapture( + result: UiFrameCaptureResult, + options: { minFrames?: number } = {}, +): void { + const minFrames = options.minFrames ?? 1; + if ( + !(result.refreshPeriodMs > 0) || + !Number.isFinite(result.refreshPeriodMs) + ) { + throw new Error( + `invalid ui refreshPeriodMs=${String(result.refreshPeriodMs)}`, + ); + } + if (!(result.refreshRateHz > 0) || !Number.isFinite(result.refreshRateHz)) { + throw new Error(`invalid ui refreshRateHz=${String(result.refreshRateHz)}`); + } + if (!Number.isInteger(result.frameCount) || result.frameCount < minFrames) { + throw new Error( + `insufficient ui frames: frameCount=${result.frameCount} (need >= ${minFrames}); source=${result.source}`, + ); + } + if ( + !Number.isFinite(result.maxFrameDurationMs) || + result.maxFrameDurationMs < 0 || + !Number.isFinite(result.totalFrameDurationMs) || + result.totalFrameDurationMs < 0 + ) { + throw new Error('invalid ui frame duration aggregates'); + } + if (result.source !== 'FrameMetrics' && result.source !== 'Choreographer') { + throw new Error(`unknown ui capture source: ${String(result.source)}`); + } + if ( + !Number.isInteger(result.droppedFrameMetrics) || + result.droppedFrameMetrics < 0 + ) { + throw new Error( + `invalid FrameMetrics drop count: ${String(result.droppedFrameMetrics)}`, + ); + } +} diff --git a/examples/benchmark-native/src/gcHarness.ts b/examples/benchmark-native/src/gcHarness.ts new file mode 100644 index 000000000000..b0576199fcec --- /dev/null +++ b/examples/benchmark-native/src/gcHarness.ts @@ -0,0 +1,52 @@ +/** + * Isolated Android GC harness — independent of DataProvider. + * + * Builds a benchmark-only Controller + createReducer + timerless GCPolicy with + * raw deterministic state, queues via createCountRef, mode=end-to-end. + * Setup/queue outside timing. Fixture shared with the Node/browser harnesses + * (examples/gc-shared). + */ +import { + Controller, + GCPolicy, + actionTypes, + createReducer, + initialState, +} from '@data-client/core'; + +import type { GCAndroidMeasurement, GCScenarioConfig } from './types'; +import { createGCFixture } from '../../gc-shared/gc-fixture.js'; +import type { Harness } from '../../gc-shared/gc-fixture.js'; + +export type { ExpectedScalars, Harness } from '../../gc-shared/gc-fixture.js'; + +const fixture = createGCFixture({ + GCPolicy, + Controller, + createReducer, + initialState, + GC: actionTypes.GC, +}); + +export const { ENTITY_KEY, ZERO_META, countRemaining, GC } = fixture; + +/** End-to-end harness: sweep dispatches and reduces synchronously. */ +export function createHarness(config: GCScenarioConfig): Harness { + return fixture.createHarness(config, 'end-to-end'); +} + +/** Throws when an accepted sample disagrees with fixture expectations. */ +export function validateMeasurement( + config: GCScenarioConfig, + harness: Harness, + sample: Pick< + GCAndroidMeasurement, + | 'actionCount' + | 'deletionCount' + | 'queueEntries' + | 'uniqueTargets' + | 'actionTargetCount' + >, +): void { + fixture.validateSample(config, harness, sample, 'end-to-end', config.control); +} diff --git a/examples/benchmark-native/src/globals.d.ts b/examples/benchmark-native/src/globals.d.ts new file mode 100644 index 000000000000..98863b0ee40e --- /dev/null +++ b/examples/benchmark-native/src/globals.d.ts @@ -0,0 +1,17 @@ +/** React Native / Hermes globals used by the benchmark harness. */ +interface PerformanceMemory { + usedJSHeapSize?: number; +} + +interface BenchPerformance { + now(): number; + memory?: PerformanceMemory; +} + +declare const performance: BenchPerformance; + +declare namespace NodeJS { + interface Global { + performance: BenchPerformance; + } +} diff --git a/examples/benchmark-native/src/measure.ts b/examples/benchmark-native/src/measure.ts new file mode 100644 index 000000000000..534322f7e8a9 --- /dev/null +++ b/examples/benchmark-native/src/measure.ts @@ -0,0 +1,230 @@ +/** + * Interaction measurement: quiet display period (prepare), UI frame capture + + * sustained JS animation, explicit cache GC inside an rAF, post frames, timer + * probe. Aggregate only — no per-candidate logs or bridge calls per frame. + * + * Engine GC must not run inside interaction timing. + * Captured GC action is released before after-memory; live store retained until dispose. + * UI frame capture / JS animation always torn down in finally (including rAF failures). + */ +import BenchNative, { readJsHeapBytes } from './BenchNative'; +import type { MemorySnapshot, UiFrameCaptureResult } from './BenchNative'; +import { + computeMaxInputDelayMs, + excessMissedFramesFromIntervals, + frameIntervalsFromTimestamps, + measureDisplayPeriodMs, + validateUiFrameCapture, + withFrameMetricsLoss, +} from './frames'; +import { + countRemaining, + createHarness, + validateMeasurement, + GC, + type Harness, +} from './gcHarness'; +import { runRafCollectSequence } from './rafCollectSequence'; +import type { + GCAndroidMeasurement, + GCPreparedSummary, + GCScenarioConfig, +} from './types'; + +interface Session { + config: GCScenarioConfig; + harness: Harness; + displayPeriodMs: number; +} + +let session: Session | null = null; +let animationHandle: number | null = null; +let onAnimationTick: ((t: number) => void) | null = null; + +export function setAnimationTickListener( + listener: ((t: number) => void) | null, +) { + onAnimationTick = listener; +} + +function startSustainedAnimation() { + stopSustainedAnimation(); + const step = () => { + onAnimationTick?.(performance.now()); + animationHandle = requestAnimationFrame(step); + }; + animationHandle = requestAnimationFrame(step); +} + +function stopSustainedAnimation() { + if (animationHandle != null) { + cancelAnimationFrame(animationHandle); + animationHandle = null; + } +} + +async function stopUiCaptureSafe(): Promise { + try { + return await BenchNative.stopUiFrameCapture(); + } catch { + return null; + } +} + +export async function prepareGCScenario( + config: GCScenarioConfig, +): Promise { + if (session) { + session.harness.dispose(); + session = null; + } + + const harness = createHarness(config); + const displayPeriodMs = await measureDisplayPeriodMs(); + + session = { config, harness, displayPeriodMs }; + + return { + queueEntries: harness.expected.queueEntries, + uniqueTargets: harness.expected.uniqueTargets, + }; +} + +export async function runGCScenario(): Promise { + if (!session) { + throw new Error( + 'prepareGCScenario() must be called before runGCScenario()', + ); + } + const { config, harness, displayPeriodMs } = session; + const { policy, expected } = harness; + + harness.clearCapturedAction(); + + const memBefore: MemorySnapshot = await BenchNative.getMemorySnapshot(); + const jsHeapBeforeBytes = readJsHeapBytes(); + + let uiFrames: UiFrameCaptureResult | null = null; + + try { + await BenchNative.startUiFrameCapture(); + startSustainedAnimation(); + + const sequence = await runRafCollectSequence({ + collectWork: () => { + const t0 = performance.now(); + let actionCount = 0; + if (config.control === 'gc') { + policy.sweep(); + const action = harness.getCapturedAction(); + actionCount = action && action.type === GC ? 1 : 0; + } + return { + totalMs: performance.now() - t0, + actionCount, + }; + }, + }); + + // Deletion accounting outside timed collect work (matches Node/browser) + let deletionCount = 0; + if (config.control === 'gc') { + const remaining = countRemaining(harness.getState(), config, expected); + deletionCount = remaining.entityDeleted + remaining.endpointDeleted; + } + + uiFrames = await BenchNative.stopUiFrameCapture(); + validateUiFrameCapture(uiFrames); + + const frameIntervalsMs = frameIntervalsFromTimestamps( + sequence.frameTimestamps, + ); + + const actionTargetCount = + config.control === 'gc' + ? expected.expectedEntitiesInAction + expected.expectedEndpointsInAction + : 0; + + const missedFrames = excessMissedFramesFromIntervals( + frameIntervalsMs, + displayPeriodMs, + ); + const maxInputDelayMs = computeMaxInputDelayMs( + sequence.timerDelayMs, + frameIntervalsMs, + displayPeriodMs, + ); + + const measurement: GCAndroidMeasurement = withFrameMetricsLoss( + { + schemaVersion: 1 as const, + totalMs: sequence.totalMs, + sliceDurationsMs: config.control === 'gc' ? [sequence.totalMs] : [], + actionCount: sequence.actionCount, + queueEntries: expected.queueEntries, + uniqueTargets: expected.uniqueTargets, + actionTargetCount, + deletionCount, + timerDelayMs: sequence.timerDelayMs, + frameIntervalsMs, + displayPeriodMs, + missedFrames, + maxInputDelayMs, + uiCaptureSource: uiFrames.source, + uiFrameCount: uiFrames.frameCount, + uiMaxFrameDurationMs: uiFrames.maxFrameDurationMs, + uiTotalFrameDurationMs: uiFrames.totalFrameDurationMs, + uiMissedFrames: uiFrames.missedFrames, + uiRefreshPeriodMs: uiFrames.refreshPeriodMs, + uiRefreshRateHz: uiFrames.refreshRateHz, + }, + uiFrames.droppedFrameMetrics, + ); + + validateMeasurement(config, harness, measurement); + + // Drop captured GC action arrays before heap snapshot; keep the live store. + harness.clearCapturedAction(); + + const memAfter: MemorySnapshot = await BenchNative.getMemorySnapshot(); + const jsHeapAfterBytes = readJsHeapBytes(); + + measurement.processPssBeforeKb = memBefore.totalPssKb; + measurement.processPssAfterKb = memAfter.totalPssKb; + measurement.processPssDeltaKb = memAfter.totalPssKb - memBefore.totalPssKb; + if (memBefore.rssKb != null) { + measurement.processRssBeforeKb = memBefore.rssKb; + } + if (memAfter.rssKb != null) { + measurement.processRssAfterKb = memAfter.rssKb; + } + if (memBefore.rssKb != null && memAfter.rssKb != null) { + measurement.processRssDeltaKb = memAfter.rssKb - memBefore.rssKb; + } + if (jsHeapBeforeBytes != null) { + measurement.jsHeapBeforeBytes = jsHeapBeforeBytes; + } + if (jsHeapAfterBytes != null) { + measurement.jsHeapAfterBytes = jsHeapAfterBytes; + } + if (jsHeapBeforeBytes != null && jsHeapAfterBytes != null) { + measurement.jsHeapDeltaBytes = jsHeapAfterBytes - jsHeapBeforeBytes; + } + + return measurement; + } finally { + // Always stop animation; stop native capture only if the success path + // did not already take the snapshot (collectWork may reject mid-sequence). + stopSustainedAnimation(); + if (uiFrames == null) { + await stopUiCaptureSafe(); + } + } +} + +export function disposeGCScenario(): void { + stopSustainedAnimation(); + if (!session) return; + session.harness.dispose(); + session = null; +} diff --git a/examples/benchmark-native/src/rafCollectSequence.ts b/examples/benchmark-native/src/rafCollectSequence.ts new file mode 100644 index 000000000000..d2841b74e770 --- /dev/null +++ b/examples/benchmark-native/src/rafCollectSequence.ts @@ -0,0 +1,169 @@ +/** + * Timed collect sequence inside rAF callbacks with fail-closed termination. + * + * If collect work (e.g. policy.sweep) throws, the Promise rejects once and any + * already-queued frame callbacks no-op so the outer finally can tear down + * animation / native capture. + */ + +export type FrameScheduler = (cb: (nowMs: number) => void) => void; + +export interface CollectWorkResult { + totalMs: number; + actionCount: number; +} + +export interface RafCollectSequenceResult { + frameTimestamps: number[]; + timerDelayMs: number; + totalMs: number; + actionCount: number; +} + +export interface RafCollectSequenceOptions { + /** Schedule a frame callback (defaults to requestAnimationFrame). */ + scheduleFrame?: FrameScheduler; + /** performance.now (or test double). */ + now?: () => number; + /** setTimeout(0) probe (or test double). */ + scheduleTimeout0?: (cb: () => void) => void; + postFrames?: number; + /** + * Synchronous collect work invoked inside the collect frame *after* the next + * rAF is queued. May throw — that rejects the sequence promptly. + */ + collectWork: () => CollectWorkResult; + /** Invoked exactly once when the sequence terminates due to error. */ + onTerminateError?: (error: unknown) => void; +} + +/** + * Manual scheduler for deterministic tests: callbacks are queued and flushed + * with synthetic timestamps. + */ +export function createManualFrameScheduler(periodMs = 16.67): { + scheduleFrame: FrameScheduler; + flush: (count?: number) => void; + pending: () => number; +} { + const queue: Array<(nowMs: number) => void> = []; + let nowMs = 0; + return { + scheduleFrame(cb) { + queue.push(cb); + }, + flush(count = 1) { + for (let i = 0; i < count; i++) { + const cb = queue.shift(); + if (!cb) return; + nowMs += periodMs; + cb(nowMs); + } + }, + pending() { + return queue.length; + }, + }; +} + +function defaultScheduleFrame(cb: (nowMs: number) => void) { + requestAnimationFrame(cb); +} + +function defaultTimeout0(cb: () => void) { + setTimeout(cb, 0); +} + +/** + * Run pre → collect → post rAF sequence. Queues the post frame *before* + * collectWork so stalls are visible; wraps work in try/catch with reject-once. + */ +export function runRafCollectSequence( + options: RafCollectSequenceOptions, +): Promise { + const scheduleFrame = options.scheduleFrame ?? defaultScheduleFrame; + const now = options.now ?? (() => performance.now()); + const scheduleTimeout0 = options.scheduleTimeout0 ?? defaultTimeout0; + const POST_FRAMES = options.postFrames ?? 6; + + return new Promise((resolve, reject) => { + /** Once true, no callback may mutate probe state or settle again. */ + let closed = false; + let phase: 'pre' | 'collect' | 'post' = 'pre'; + let postRemaining = POST_FRAMES; + const frameTimestamps: number[] = []; + let timerDelayMs = 0; + let totalMs = 0; + let actionCount = 0; + + const settleReject = (error: unknown) => { + if (closed) return; + closed = true; + try { + options.onTerminateError?.(error); + } catch { + // ignore cleanup listener failures + } + reject(error); + }; + + const settleResolve = () => { + if (closed) return; + closed = true; + resolve({ + frameTimestamps, + timerDelayMs, + totalMs, + actionCount, + }); + }; + + const frame = (frameNow: number) => { + if (closed) return; + + try { + frameTimestamps.push(frameNow); + + if (phase === 'pre') { + phase = 'collect'; + scheduleFrame(frame); + return; + } + + if (phase === 'collect') { + // Queue next rAF BEFORE collect work so a stall is observable. + phase = 'post'; + postRemaining = POST_FRAMES; + scheduleFrame(frame); + + const beforeCollection = now(); + scheduleTimeout0(() => { + if (closed) return; + timerDelayMs = now() - beforeCollection; + }); + + const result = options.collectWork(); + totalMs = result.totalMs; + actionCount = result.actionCount; + return; + } + + // post + postRemaining--; + if (postRemaining > 0) { + scheduleFrame(frame); + return; + } + + scheduleTimeout0(() => { + if (closed) return; + settleResolve(); + }); + } catch (error) { + settleReject(error); + } + }; + + scheduleFrame(frame); + }); +} diff --git a/examples/benchmark-native/src/report.ts b/examples/benchmark-native/src/report.ts new file mode 100644 index 000000000000..6cda0d00c295 --- /dev/null +++ b/examples/benchmark-native/src/report.ts @@ -0,0 +1,228 @@ +import { scenarioId } from './scenario'; +import type { + AndroidEnvironment, + BuildManifestV1, + BuildSidecarV1, + GCAndroidMeasurement, + GCMeasurementReport, + GCScenarioConfig, + GCScenarioReport, + NumberSummary, +} from './types'; +import { summarizeNumbers } from '../../gc-shared/stats.js'; + +export { summarizeNumbers }; + +function optionalField( + samples: GCAndroidMeasurement[], + key: keyof GCAndroidMeasurement, +): number[] { + return samples + .map(s => s[key]) + .filter((v): v is number => typeof v === 'number' && Number.isFinite(v)); +} + +function putOptionalSummary( + summary: Record, + key: string, + values: number[], +) { + if (values.length) { + summary[key] = summarizeNumbers(values); + } +} + +export function summarizeGCSamples( + samples: GCAndroidMeasurement[], +): Record { + const summary: Record = { + totalMs: summarizeNumbers(samples.map(s => s.totalMs)), + actionCount: summarizeNumbers(samples.map(s => s.actionCount)), + queueEntries: summarizeNumbers(samples.map(s => s.queueEntries)), + uniqueTargets: summarizeNumbers(samples.map(s => s.uniqueTargets)), + actionTargetCount: summarizeNumbers(samples.map(s => s.actionTargetCount)), + deletionCount: summarizeNumbers(samples.map(s => s.deletionCount)), + timerDelayMs: summarizeNumbers(samples.map(s => s.timerDelayMs)), + displayPeriodMs: summarizeNumbers(samples.map(s => s.displayPeriodMs)), + missedFrames: summarizeNumbers(samples.map(s => s.missedFrames)), + maxInputDelayMs: summarizeNumbers(samples.map(s => s.maxInputDelayMs)), + uiFrameCount: summarizeNumbers(samples.map(s => s.uiFrameCount)), + uiMaxFrameDurationMs: summarizeNumbers( + samples.map(s => s.uiMaxFrameDurationMs), + ), + uiTotalFrameDurationMs: summarizeNumbers( + samples.map(s => s.uiTotalFrameDurationMs), + ), + uiMissedFrames: summarizeNumbers(samples.map(s => s.uiMissedFrames)), + uiDroppedFrameMetrics: summarizeNumbers( + samples.map(s => s.uiDroppedFrameMetrics), + ), + uiRefreshPeriodMs: summarizeNumbers(samples.map(s => s.uiRefreshPeriodMs)), + }; + + const slices = samples.flatMap(s => s.sliceDurationsMs ?? []); + if (slices.length) { + summary.sliceDurationsMs = summarizeNumbers(slices); + } + const frameIntervals = samples.flatMap(s => s.frameIntervalsMs ?? []); + if (frameIntervals.length) { + summary.frameIntervalsMs = summarizeNumbers(frameIntervals); + } + + putOptionalSummary( + summary, + 'processPssBeforeKb', + optionalField(samples, 'processPssBeforeKb'), + ); + putOptionalSummary( + summary, + 'processPssAfterKb', + optionalField(samples, 'processPssAfterKb'), + ); + putOptionalSummary( + summary, + 'processPssDeltaKb', + optionalField(samples, 'processPssDeltaKb'), + ); + putOptionalSummary( + summary, + 'processRssBeforeKb', + optionalField(samples, 'processRssBeforeKb'), + ); + putOptionalSummary( + summary, + 'processRssAfterKb', + optionalField(samples, 'processRssAfterKb'), + ); + putOptionalSummary( + summary, + 'processRssDeltaKb', + optionalField(samples, 'processRssDeltaKb'), + ); + putOptionalSummary( + summary, + 'jsHeapBeforeBytes', + optionalField(samples, 'jsHeapBeforeBytes'), + ); + putOptionalSummary( + summary, + 'jsHeapAfterBytes', + optionalField(samples, 'jsHeapAfterBytes'), + ); + putOptionalSummary( + summary, + 'jsHeapDeltaBytes', + optionalField(samples, 'jsHeapDeltaBytes'), + ); + + return summary; +} + +export const REPORT_UNITS: Record = { + totalMs: 'milliseconds', + sliceDurationsMs: 'milliseconds', + timerDelayMs: 'milliseconds', + frameIntervalsMs: 'milliseconds', + displayPeriodMs: 'milliseconds', + maxInputDelayMs: 'milliseconds (proxy; not pointer latency)', + missedFrames: 'count (JS rAF interval nearest-period excess)', + uiCaptureSource: + 'FrameMetrics (duration/ceil) | Choreographer (interval/round)', + uiFrameCount: 'count', + uiMaxFrameDurationMs: + 'milliseconds (FrameMetrics duration or Choreographer interval)', + uiTotalFrameDurationMs: + 'milliseconds (sum of FrameMetrics durations or Choreographer intervals)', + uiMissedFrames: + 'count (FrameMetrics: ceil(duration/period)−1; Choreographer: round(interval/period)−1)', + uiDroppedFrameMetrics: + 'count (FrameMetrics dropCountSinceLastInvocation; not missed frames)', + uiFrameMetricsDropped: + 'boolean (true when uiDroppedFrameMetrics is non-zero)', + uiRefreshPeriodMs: 'milliseconds', + uiRefreshRateHz: 'hertz', + processPssBeforeKb: 'kilobytes (Android Debug.MemoryInfo totalPss)', + processPssAfterKb: 'kilobytes (Android Debug.MemoryInfo totalPss)', + processPssDeltaKb: 'kilobytes (after − before)', + processRssBeforeKb: 'kilobytes (when API exposes RSS)', + processRssAfterKb: 'kilobytes (when API exposes RSS)', + processRssDeltaKb: 'kilobytes (after − before; when RSS available)', + jsHeapBeforeBytes: 'bytes (performance.memory.usedJSHeapSize when exposed)', + jsHeapAfterBytes: 'bytes (performance.memory.usedJSHeapSize when exposed)', + jsHeapDeltaBytes: 'bytes (after − before; when JS heap available)', + actionCount: 'count', + queueEntries: 'count', + uniqueTargets: 'count', + actionTargetCount: 'count', + deletionCount: 'count', +}; + +export const MEMORY_SEMANTICS_DESCRIPTION = + 'After interaction timing, release captured GC action arrays, keep the live store until dispose, then snapshot process/JS memory. System.gc() is not used — it does not force Hermes collection. Engine GC must not run inside interaction timing. Without a forced Hermes GC, before/after JS heap and process PSS/RSS are observational and noisy: they are not sufficient alone for a memory gate. Compare repeated gc vs no-gc controls on the same device/build series; treat deltas as supporting evidence beside interaction/frame metrics.'; + +export function buildScenarioReport( + config: GCScenarioConfig, + samples: GCAndroidMeasurement[], +): GCScenarioReport { + return { + id: scenarioId(config), + platform: 'android', + candidateKind: config.candidateKind, + pattern: config.pattern, + count: config.count, + mode: 'interaction', + control: config.control, + samples, + summary: summarizeGCSamples(samples), + }; +} + +export function buildMeasurementReport(args: { + environment: AndroidEnvironment; + config: GCScenarioConfig; + samples: GCAndroidMeasurement[]; + manifest: BuildManifestV1; + label?: string; + apkSizeBytes?: number; + hermesBytecodeBytes?: number; + hermesAssetsBytes?: number; + sidecar?: BuildSidecarV1; + installedApkSha256?: string; +}): GCMeasurementReport { + const scenario = buildScenarioReport(args.config, args.samples); + return { + schemaVersion: 1, + units: { ...REPORT_UNITS }, + memorySemantics: { + model: 'keep-store-drop-observer', + description: MEMORY_SEMANTICS_DESCRIPTION, + }, + build: { + buildId: args.manifest.buildId, + sourceDigest: args.manifest.sourceDigest, + gitCommit: args.manifest.gitCommit, + gitDirty: args.manifest.gitDirty, + label: args.label, + apkSizeBytes: args.apkSizeBytes, + hermesBytecodeBytes: args.hermesBytecodeBytes, + hermesAssetsBytes: args.hermesAssetsBytes, + sidecar: args.sidecar + ? { + buildId: args.sidecar.buildId, + sourceDigest: args.sidecar.sourceDigest, + apkSha256: args.sidecar.apkSha256, + apkPath: args.sidecar.apkPath, + sidecarId: args.sidecar.sidecarId, + } + : undefined, + installedApkSha256: args.installedApkSha256, + }, + environment: args.environment, + config: { + samplesPerScenario: args.samples.length, + filter: null, + scenarioId: scenario.id, + }, + scenarios: [scenario], + }; +} diff --git a/examples/benchmark-native/src/runOrchestration.ts b/examples/benchmark-native/src/runOrchestration.ts new file mode 100644 index 000000000000..3f28490f0521 --- /dev/null +++ b/examples/benchmark-native/src/runOrchestration.ts @@ -0,0 +1,94 @@ +/** + * Shared measurement orchestration for manual Run and intent auto-run. + */ +import BenchNative from './BenchNative'; +import { disposeGCScenario, prepareGCScenario, runGCScenario } from './measure'; +import { buildMeasurementReport } from './report'; +import { scenarioId } from './scenario'; +import type { + AndroidEnvironment, + BuildManifestV1, + GCAndroidMeasurement, + GCMeasurementReport, + GCScenarioConfig, +} from './types'; +import { validateSampleCount, validateScenarioConfig } from './validateConfig'; + +export interface ExecuteMeasurementArgs { + config: GCScenarioConfig; + samples: number; + label?: string; + onStatus?: (message: string) => void; +} + +export interface ExecuteMeasurementResult { + report: GCMeasurementReport; + path: string; + scenarioId: string; + samples: GCAndroidMeasurement[]; +} + +async function readEnvironment(): Promise { + const envRaw = await BenchNative.getEnvironment(); + return { platform: 'android', ...envRaw }; +} + +export async function readEmbeddedBuildManifest(): Promise { + const raw = await BenchNative.getBuildManifest(); + const text = raw.json; + if (!text) { + throw new Error('embedded build-manifest.json empty'); + } + const parsed = JSON.parse(text) as BuildManifestV1; + if (parsed.schemaVersion !== 1 || !parsed.buildId || !parsed.sourceDigest) { + throw new Error('invalid embedded BuildManifest v1'); + } + return parsed; +} + +export async function executeMeasurement( + args: ExecuteMeasurementArgs, +): Promise { + validateScenarioConfig(args.config); + validateSampleCount(args.samples); + + const id = scenarioId(args.config); + args.onStatus?.(`Running ${id}…`); + + const environment = await readEnvironment(); + const manifest = await readEmbeddedBuildManifest(); + const collected: GCAndroidMeasurement[] = []; + + try { + for (let i = 0; i < args.samples; i++) { + args.onStatus?.(`Sample ${i + 1}/${args.samples}: prepare…`); + await prepareGCScenario(args.config); + // Let the React status commit paint before capture so it does not + // contaminate UI frame / interaction measurement (outside timing). + args.onStatus?.(`Sample ${i + 1}/${args.samples}: measure…`); + await new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); + collected.push(await runGCScenario()); + disposeGCScenario(); + } + + const report = buildMeasurementReport({ + environment, + config: args.config, + samples: collected, + manifest, + label: args.label, + }); + + const { path } = await BenchNative.writeReport(JSON.stringify(report)); + args.onStatus?.( + `Done — wrote ${path} (buildId=${manifest.buildId.slice(0, 8)}… totalMs median≈${report.scenarios[0].summary.totalMs?.median?.toFixed?.(2) ?? 'n/a'})`, + ); + + return { report, path, scenarioId: id, samples: collected }; + } catch (e) { + disposeGCScenario(); + throw e; + } +} diff --git a/examples/benchmark-native/src/scenario.ts b/examples/benchmark-native/src/scenario.ts new file mode 100644 index 000000000000..b62479e771a1 --- /dev/null +++ b/examples/benchmark-native/src/scenario.ts @@ -0,0 +1,34 @@ +import type { + CandidateKind, + Control, + GCScenarioConfig, + Pattern, +} from './types'; +import { scenarioId as sharedScenarioId } from '../../gc-shared/protocol.js'; + +export { splitMixedCount } from '../../gc-shared/protocol.js'; + +export interface ScenarioAxes { + platform: 'android'; + candidateKind: CandidateKind; + pattern: Pattern; + count: number; + mode: 'interaction'; + control: Control; +} + +/** + * Stable scenario ID: `android/{kind}/{pattern}/{count}/interaction/{control}`. + * Throws on invalid axes. Listing/filtering lives in the shared protocol + * (`examples/gc-shared/protocol-cli.js list android`, used by run-matrix.sh). + */ +export function scenarioId(axes: ScenarioAxes | GCScenarioConfig): string { + return sharedScenarioId({ + platform: 'android', + candidateKind: axes.candidateKind, + pattern: axes.pattern, + count: axes.count, + mode: 'interaction', + control: axes.control, + }); +} diff --git a/examples/benchmark-native/src/types.ts b/examples/benchmark-native/src/types.ts new file mode 100644 index 000000000000..415c99c23257 --- /dev/null +++ b/examples/benchmark-native/src/types.ts @@ -0,0 +1,192 @@ +/** + * Local schemaVersion 1 types mirroring the documented GC measurement protocol. + * Axes, ids, fixture, and stats are shared via examples/gc-shared. + */ +import { CANONICAL_COUNTS } from '../../gc-shared/protocol.js'; +import type { NumberSummary } from '../../gc-shared/stats.js'; + +export type CandidateKind = 'entity' | 'endpoint' | 'mixed'; +export type Pattern = 'unique' | 'duplicate'; +export type Control = 'gc' | 'no-gc'; +export type Mode = 'interaction'; +export type UiCaptureSource = 'FrameMetrics' | 'Choreographer'; + +/** Canonical scenario counts (shared protocol, examples/gc-shared). */ +export { CANONICAL_COUNTS }; +export type CanonicalCount = (typeof CANONICAL_COUNTS)[number]; + +/** Axes for Android GC scenarios. */ +export interface GCScenarioConfig { + candidateKind: CandidateKind; + /** `duplicate` is entity-only (one path released `count` times). */ + pattern: Pattern; + count: number; + control: Control; +} + +export interface GCPreparedSummary { + queueEntries: number; + uniqueTargets: number; +} + +/** + * JS-side interaction measurement (schemaVersion 1). + * Native UI-frame and process-memory fields are attached by the runner. + */ +export interface GCAndroidMeasurement { + schemaVersion: 1; + totalMs: number; + /** Monolithic baseline: exactly `[totalMs]` when control is `gc`; empty for `no-gc`. */ + sliceDurationsMs: number[]; + actionCount: number; + queueEntries: number; + uniqueTargets: number; + actionTargetCount: number; + deletionCount: number; + timerDelayMs: number; + frameIntervalsMs: number[]; + displayPeriodMs: number; + missedFrames: number; + maxInputDelayMs: number; + /** Native UI capture source — required when UI metrics are present. */ + uiCaptureSource: UiCaptureSource; + uiFrameCount: number; + /** Aggregate max single-frame duration/interval (ms). */ + uiMaxFrameDurationMs: number; + /** Aggregate sum of frame durations/intervals (ms). */ + uiTotalFrameDurationMs: number; + uiMissedFrames: number; + /** + * FrameMetrics `dropCountSinceLastInvocation` summed over the capture. + * Lost reports, not jank. Not included in `missedFrames` or `uiMissedFrames`. + */ + uiDroppedFrameMetrics: number; + /** True when `uiDroppedFrameMetrics` is non-zero. */ + uiFrameMetricsDropped: boolean; + uiRefreshPeriodMs: number; + uiRefreshRateHz: number; + processPssBeforeKb?: number; + processPssAfterKb?: number; + processPssDeltaKb?: number; + processRssBeforeKb?: number; + processRssAfterKb?: number; + processRssDeltaKb?: number; + jsHeapBeforeBytes?: number; + jsHeapAfterBytes?: number; + jsHeapDeltaBytes?: number; +} + +export type { NumberSummary }; + +export interface GCScenarioReport { + id: string; + platform: 'android'; + candidateKind: CandidateKind; + pattern: Pattern; + count: number; + mode: Mode; + control: Control; + samples: GCAndroidMeasurement[]; + summary: Record; +} + +export interface AndroidEnvironment { + platform: 'android'; + apiLevel: number; + release: string; + manufacturer: string; + model: string; + device: string; + brand: string; + buildType: string; + applicationId: string; + hermesEnabled: boolean; + hermesRuntimeProperties?: Record; + refreshRateHz: number; + refreshPeriodMs: number; +} + +/** Embedded BuildManifest v1 (baked into APK assets at prepare time). */ +export interface BuildManifestV1 { + schemaVersion: 1; + /** + * Source-build identity: deterministic sha256 over + * schemaVersion, gitCommit, gitDirty, sourceDigest (not APK bytes). + */ + buildId: string; + gitCommit: string; + gitDirty: boolean; + /** Content digest of the app tree plus the package files Metro bundles. */ + sourceDigest: string; + createdAt: string; +} + +/** Host sidecar written after APK assemble (authority for collection). */ +export interface BuildSidecarV1 { + schemaVersion: 1; + /** Same source-build identity as the embedded BuildManifest. */ + buildId: string; + sourceDigest: string; + gitCommit: string; + gitDirty: boolean; + apkPath: string; + /** Artifact identity: sha256 of the single release APK bytes. */ + apkSha256: string; + apkSizeBytes: number; + /** Artifact-aware id: sha256(buildId ∥ apkSha256). */ + sidecarId: string; + builtAt: string; +} + +export interface GCMeasurementReport { + schemaVersion: 1; + units: Record; + memorySemantics: { + model: 'keep-store-drop-observer'; + description: string; + }; + build: { + /** + * Source-build identity from the embedded BuildManifest + * (deterministic over schemaVersion/gitCommit/gitDirty/sourceDigest). + * Matched to sidecar.buildId at collection — not live checkout authority. + */ + buildId: string; + sourceDigest: string; + gitCommit: string; + gitDirty: boolean; + label?: string; + apkSizeBytes?: number; + hermesBytecodeBytes?: number; + hermesAssetsBytes?: number; + /** Host sidecar fields attached at collection — not live checkout. */ + sidecar?: { + buildId: string; + sourceDigest: string; + /** Artifact identity of the release APK. */ + apkSha256: string; + apkPath: string; + sidecarId: string; + }; + /** sha256 of the APK actually installed on device (must equal sidecar.apkSha256). */ + installedApkSha256?: string; + }; + environment: AndroidEnvironment; + config: { + samplesPerScenario: number; + filter: string | null; + scenarioId: string; + }; + scenarios: GCScenarioReport[]; +} + +export interface LaunchConfig { + autoRun: boolean; + candidateKind: CandidateKind; + pattern: Pattern; + count: number; + control: Control; + samples: number; + /** Optional human label only — not provenance authority. */ + label?: string; +} diff --git a/examples/benchmark-native/src/validateConfig.ts b/examples/benchmark-native/src/validateConfig.ts new file mode 100644 index 000000000000..978af5ee5334 --- /dev/null +++ b/examples/benchmark-native/src/validateConfig.ts @@ -0,0 +1,127 @@ +import type { + CandidateKind, + Control, + GCScenarioConfig, + LaunchConfig, + Pattern, +} from './types'; +import { + MAX_SAMPLES, + validateAxes, + validateSampleCount as sharedValidateSampleCount, +} from '../../gc-shared/protocol.js'; + +export { + CANDIDATE_KINDS, + CONTROLS, + MAX_SAMPLES, + PATTERNS, +} from '../../gc-shared/protocol.js'; + +export class ConfigValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConfigValidationError'; + } +} + +/** Rethrow shared-protocol validation failures as ConfigValidationError. */ +function asConfigError(validate: () => void): void { + try { + validate(); + } catch (err) { + throw new ConfigValidationError((err as Error).message); + } +} + +/** + * Validate scenario axes. Throws ConfigValidationError on invalid input. + * Does not coerce — callers must pass already-parsed values or use parseLaunchConfig. + */ +export function validateScenarioConfig(config: GCScenarioConfig): void { + asConfigError(() => validateAxes(config)); +} + +export function validateSampleCount(samples: number): void { + asConfigError(() => sharedValidateSampleCount(samples)); +} + +export function validateLaunchConfig(config: LaunchConfig): void { + validateScenarioConfig({ + candidateKind: config.candidateKind, + pattern: config.pattern, + count: config.count, + control: config.control, + }); + validateSampleCount(config.samples); +} + +/** + * Parse raw launch/intent/env fields into a LaunchConfig. + * Missing optional fields get safe defaults; invalid values throw. + */ +export function parseLaunchConfig(raw: { + autoRun?: unknown; + candidateKind?: unknown; + pattern?: unknown; + count?: unknown; + control?: unknown; + samples?: unknown; + label?: unknown; +}): LaunchConfig { + const autoRun = Boolean(raw.autoRun); + + const candidateKindRaw = + raw.candidateKind === undefined || raw.candidateKind === null + ? 'entity' + : String(raw.candidateKind); + const patternRaw = + raw.pattern === undefined || raw.pattern === null + ? 'unique' + : String(raw.pattern); + const controlRaw = + raw.control === undefined || raw.control === null + ? 'gc' + : String(raw.control); + + if (raw.count !== undefined && raw.count !== null && raw.count !== '') { + const countNum = Number(raw.count); + if (!Number.isInteger(countNum)) { + throw new ConfigValidationError( + `invalid count=${String(raw.count)}; expected integer canonical count`, + ); + } + } + const count = + raw.count === undefined || raw.count === null || raw.count === '' + ? 1000 + : Number(raw.count); + + const samplesRaw = + raw.samples === undefined || raw.samples === null || raw.samples === '' + ? 1 + : Number(raw.samples); + if (!Number.isFinite(samplesRaw)) { + throw new ConfigValidationError( + `invalid samples=${String(raw.samples)}; expected integer 1..${MAX_SAMPLES}`, + ); + } + const samples = Math.floor(samplesRaw); + + const label = + typeof raw.label === 'string' && raw.label.length > 0 + ? raw.label + : undefined; + + const config: LaunchConfig = { + autoRun, + candidateKind: candidateKindRaw as CandidateKind, + pattern: patternRaw as Pattern, + count, + control: controlRaw as Control, + samples, + label, + }; + validateLaunchConfig(config); + return config; +} diff --git a/examples/benchmark-native/tsconfig.json b/examples/benchmark-native/tsconfig.json new file mode 100644 index 000000000000..8d691b165b8f --- /dev/null +++ b/examples/benchmark-native/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@react-native/typescript-config", + "compilerOptions": { + "types": ["jest"] + }, + "include": ["**/*.ts", "**/*.tsx", "src/globals.d.ts"], + "exclude": ["**/node_modules", "**/Pods", "android", "artifacts"] +} diff --git a/examples/benchmark-react/.gitignore b/examples/benchmark-react/.gitignore new file mode 100644 index 000000000000..faf1b1e9bc7f --- /dev/null +++ b/examples/benchmark-react/.gitignore @@ -0,0 +1,2 @@ +# Generated GC detailed report (default path; override with BENCH_GC_OUTPUT) +gc-measurement-output.json diff --git a/examples/benchmark-react/AGENTS.md b/examples/benchmark-react/AGENTS.md index bbce217dcc65..f643b09ad034 100644 --- a/examples/benchmark-react/AGENTS.md +++ b/examples/benchmark-react/AGENTS.md @@ -32,7 +32,7 @@ Filtering: `yarn bench --lib data-client --size small --action update` ## Scenario System -`BASE_SCENARIOS` in `bench/scenarios.ts` × `LIBRARIES` via `flatMap`. `onlyLibs` restricts to specific libs. CI runs data-client hot-path only (no memory/startup/deterministic). Memory is opt-in locally (`--action memory`). Convergent timing uses single page load with adaptive iterations and early stopping on statistical convergence. Ref-stability scenarios run once (deterministic count, not ops/s). +`BASE_SCENARIOS` in `bench/scenarios.ts` × `LIBRARIES` via `flatMap`. `onlyLibs` restricts to specific libs. CI runs data-client hot-path only (no memory/startup/deterministic). Memory is opt-in locally (`--action memory`). Convergent timing uses single page load with adaptive iterations and early stopping on statistical convergence. Ref-stability scenarios run once (deterministic count, not ops/s). Cache GC is not a `Scenario`: `yarn bench:gc` runs `bench/gc-runner.ts`, a fixed-sample runner over the shared protocol (`examples/gc-shared`) that drives an isolated Controller harness (`src/data-client/gcBrowserHarness.ts`, lazy chunk), not the DataProvider `benchGC` singleton. `bench/browser.ts` holds page/Chromium plumbing shared by both runners. `--scenario` is a display-name substring for `yarn bench` but slash-bounded stable-id segments for `yarn bench:gc`. ## Update Data Flow @@ -75,3 +75,7 @@ Filtering: `yarn bench --lib data-client --size small --action update` | `BENCH_TRACE=true` | Chrome tracing for duration scenarios | | `BENCH_V8_TRACE=true` | Launch Chromium with `--trace-opt --trace-deopt`; output to `v8-trace.log` | | `BENCH_V8_DEOPT=true` | Launch Chromium with `--prof`; V8 logs to `v8-logs/` | +| `BENCH_GC_SAMPLES` | `bench:gc` samples per GC scenario (default 5; also `--samples`) | +| `BENCH_GC_OUTPUT` | `bench:gc` detailed GC JSON path (default `gc-measurement-output.json`, gitignored) | + +BuildManifest v1 is written to `dist/gc-build-manifest.json` after webpack (`yarn build`). `buildId` digests schemaVersion, commit, dirty, sourceDigest, and sorted artifact hashes. GC runs require a matching served manifest. diff --git a/examples/benchmark-react/README.md b/examples/benchmark-react/README.md index 6cb1d7d29a47..4f2d30026034 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -30,6 +30,7 @@ All implementations share presentational components, fixture data, fetch functio - **With network (local)** — Same shared-author update but with simulated network delay (consistent ms per "request"). Normalized caches propagate via a single store update; query-keyed caches invalidate and refetch affected queries. **Not run in CI** — run locally with `yarn bench` (no `CI` env) to include these. - **Memory (local only)** — Heap delta after repeated mount/unmount cycles. - **Startup (local only)** — FCP and task duration via CDP `Performance.getMetrics`. +- **GC (local only)** — Isolated data-client cache GC interaction + heap deltas on Chromium. Separate runner (`yarn bench:gc`, `bench/gc-runner.ts`); never part of `yarn bench` or CI. ## Scenarios @@ -52,6 +53,30 @@ All implementations share presentational components, fixture data, fetch functio - **Memory mount/unmount cycle** (`memory-mount-unmount-cycle`) — Mount 500 issues, unmount, repeat 10 times; report JS heap delta (bytes) via CDP. Surfaces leaks or unbounded growth. +**GC (local only, data-client)** + +Isolated monolithic cache-GC baseline (not V8/engine GC). Builds a separate Controller + timerless GCPolicy with raw deterministic state — no 100k React rows, no network. Mode is always end-to-end (sweep → reduce). + +- **Axes:** `candidateKind` entity|endpoint|mixed × `pattern` unique|duplicate (duplicate is entity-only) × `count` 1k/10k/100k × `control` gc|no-gc +- **Timing boundaries:** prepare (fixtures + queue + quiet `displayPeriodMs` + dynamic harness import) is untimed; `runGCScenario` measures only sweep/no-op inside a Chromium-calibrated probe (rAF registers next rAF, then `setTimeout(0)` runs collection post-paint while a frame is pending — Chromium-specific calibration, not a web guarantee); engine GC for heap snapshots is outside interaction timing +- **Probes:** wall-clock frame timestamps, `setTimeout(0)` `timerDelayMs`, `missedFrames` via nearest-period excess over measured `displayPeriodMs`, long-task overlap filter + `takeRecords`. `maxInputDelayMs` is a responsiveness proxy (not pointer input). Validation runs a synthetic 40–50 ms block calibration confirming the probe spans pending frames +- **Stable IDs:** detailed report scenario ids are `browser/{kind}/{pattern}/{count}/end-to-end/{control}` (not display names) +- **Failures:** incomplete runs write `complete:false` with failure records and exit nonzero +- **Bundle fairness:** GC harness is a dynamic `import()` chunk loaded during prepare — not in the initial data-client bundle +- **Provenance:** `yarn build` writes `dist/gc-build-manifest.json` (BuildManifest v1). GC runner verifies local source digest + artifact hashes and matching served manifest `buildId` before measuring; report embeds verified provenance (never live HEAD) +- **Outputs:** per-scenario medians on stderr; nothing on stdout. Detailed schemaVersion 1 JSON is always written to `gc-measurement-output.json` (gitignored; override path with `BENCH_GC_OUTPUT`). Failed runs still write `complete:false` reports before exiting nonzero +- **Shared code:** fixture, axes/ids/filter, frame helpers, and stats come from [`examples/gc-shared`](../gc-shared/README.md), shared with the Node and Android GC harnesses + +```bash +yarn build # webpack + BuildManifest +yarn bench:gc # all GC scenarios, 5 samples each +yarn bench:gc --scenario /1000/ --samples 3 # 1k only (not 10k/100k) +yarn bench:gc --scenario entity/unique/100000 +BENCH_GC_OUTPUT=./custom-gc.json yarn bench:gc --scenario mixed/unique/100000 +yarn test:gc-metrics +yarn test:gc-provenance # after build +``` + **Startup (local only)** - **Startup FCP** (`startup-fcp`) — First Contentful Paint time via CDP `Performance.getMetrics`. @@ -174,6 +199,14 @@ CI convergence targets: 2% (small scenarios), 3% (large scenarios). Reported mar | `--action ` | `BENCH_ACTION` | Filter by action group (`mount`, `update`, `mutation`, `memory`) or exact action name. Memory is **not run by default**; use `--action memory` to include. | | `--scenario ` | `BENCH_SCENARIO` | Substring filter on scenario name | + `yarn bench:gc` (`bench/gc-runner.ts`) takes its own flags: + + | CLI flag | Env var | Description | + |---|---|---| + | `--scenario ` | `BENCH_SCENARIO` | Slash-bounded segments of the stable id `browser/{kind}/{pattern}/{count}/end-to-end/{control}` (e.g. `100000`, `entity/unique/100000`, `/1000/`), or `^prefix`. Same syntax as the Node/Android GC harnesses; unlike `yarn bench`, not a substring match. | + | `--samples ` | `BENCH_GC_SAMPLES` | Samples per GC scenario (default 5) | + | (env only) | `BENCH_GC_OUTPUT` | Override detailed GC report path (default `gc-measurement-output.json`) | + CLI flags take precedence over env vars. Examples: ```bash @@ -191,6 +224,7 @@ CI convergence targets: 2% (small scenarios), 3% (large scenarios). Reported mar yarn bench:small # --size small yarn bench:large # --size large yarn bench:dc # --lib data-client + yarn bench:gc # isolated cache GC (bench/gc-runner.ts; data-client only) ``` 5. **Scenario sizes** diff --git a/examples/benchmark-react/bench/browser.ts b/examples/benchmark-react/bench/browser.ts new file mode 100644 index 000000000000..280e66ebf11e --- /dev/null +++ b/examples/benchmark-react/bench/browser.ts @@ -0,0 +1,154 @@ +/// +/** + * Browser/page plumbing shared by the hot-path runner (runner.ts) and the GC + * runner (gc-runner.ts): served app URL, Chromium launch flags, and per-load + * bench page setup. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { chromium } from 'playwright'; +import type { Browser, BrowserServer, Locator, Page } from 'playwright'; + +import { NETWORK_SIM_CONFIG } from './scenarios.js'; +import type { Scenario } from '../src/shared/types.js'; + +export const BASE_URL = + process.env.BENCH_BASE_URL ?? + `http://localhost:${process.env.BENCH_PORT ?? '5173'}`; +const BENCH_V8_TRACE = process.env.BENCH_V8_TRACE === 'true'; +export const BENCH_V8_DEOPT = process.env.BENCH_V8_DEOPT === 'true'; +export const V8_LOG_DIR = path.resolve('v8-logs'); + +/** Fresh page load of one library app; waits for `[data-app-ready]` and the harness. */ +export async function setupBenchPage( + page: Page, + lib: string, + scenario: Pick, + networkSim: boolean, +): Promise<{ harness: Locator; bench: any }> { + await page.goto(`${BASE_URL}/${lib}/`, { + waitUntil: 'networkidle', + timeout: 120000, + }); + await page.waitForSelector('[data-app-ready]', { + timeout: 120000, + state: 'attached', + }); + + const harness = page.locator('[data-bench-harness]'); + await harness.waitFor({ state: 'attached' }); + + const bench = await page.evaluateHandle('window.__BENCH__'); + if (await bench.evaluate(b => b == null)) + throw new Error('window.__BENCH__ not found'); + + if (networkSim) { + await (bench as any).evaluate( + (api: any, cfg: { baseLatencyMs: number; recordsPerMs: number }) => + api.setNetworkSim(cfg), + NETWORK_SIM_CONFIG, + ); + } + + if (scenario.renderLimit != null) { + await (bench as any).evaluate( + (api: any, n: number) => api.setRenderLimit(n), + scenario.renderLimit, + ); + } + + return { harness, bench }; +} + +/** Chromium from `launch()` does not expose `process()`; use `launchServer` when piping V8 trace output. */ +export async function launchBenchChromium(): Promise<{ + browser: Browser; + closeBenchBrowser: () => Promise; +}> { + const launchOpts = { + headless: true, + args: buildLaunchArgs(), + }; + + if (BENCH_V8_TRACE) { + const server: BrowserServer = await chromium.launchServer(launchOpts); + let v8TraceStream: fs.WriteStream | undefined; + const proc = server.process(); + if (proc?.stderr ?? proc?.stdout) { + v8TraceStream = fs.createWriteStream('v8-trace.log'); + proc.stderr?.pipe(v8TraceStream, { end: false }); + proc.stdout?.pipe(v8TraceStream, { end: false }); + process.stderr.write( + 'V8 trace output → v8-trace.log (root browser process stderr/stdout)\n', + ); + } else { + process.stderr.write( + 'Warning: BENCH_V8_TRACE but browser server process streams unavailable; v8-trace.log may be empty.\n', + ); + } + const browser = await chromium.connect({ wsEndpoint: server.wsEndpoint() }); + return { + browser, + closeBenchBrowser: async () => { + await browser.close(); + await server.close(); + if (v8TraceStream) { + v8TraceStream.end(); + process.stderr.write( + '\nV8 opt/deopt trace written to v8-trace.log\n', + ); + } + }, + }; + } + + const browser = await chromium.launch(launchOpts); + return { + browser, + closeBenchBrowser: () => browser.close(), + }; +} + +function buildLaunchArgs(): string[] { + const args = [ + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', + '--disable-hang-monitor', + ]; + const jsFlags: string[] = []; + if (BENCH_V8_TRACE) { + jsFlags.push('--trace-opt', '--trace-deopt'); + } + if (BENCH_V8_DEOPT) { + fs.rmSync(V8_LOG_DIR, { recursive: true, force: true }); + fs.mkdirSync(V8_LOG_DIR, { recursive: true }); + jsFlags.push('--prof', `--logfile=${V8_LOG_DIR}/v8-%p.log`); + } + if (jsFlags.length > 0) args.push(`--js-flags=${jsFlags.join(' ')}`); + return args; +} + +export function reportV8Logs(): void { + if (!BENCH_V8_DEOPT) return; + try { + const logs = fs.readdirSync(V8_LOG_DIR).filter(f => f.endsWith('.log')); + if (logs.length === 0) return; + process.stderr.write(`\nV8 profiling logs written to ${V8_LOG_DIR}/:\n`); + for (const log of logs) { + const size = fs.statSync(path.join(V8_LOG_DIR, log)).size; + process.stderr.write(` ${log} (${(size / 1024).toFixed(1)} KB)\n`); + } + const largest = logs.reduce((a, b) => { + const sa = fs.statSync(path.join(V8_LOG_DIR, a)).size; + const sb = fs.statSync(path.join(V8_LOG_DIR, b)).size; + return sa >= sb ? a : b; + }); + process.stderr.write( + `\nProcess the renderer log (typically the largest file) with:\n` + + ` node --prof-process ${V8_LOG_DIR}/${largest}\n\n`, + ); + } catch { + // best-effort reporting + } +} diff --git a/examples/benchmark-react/bench/build-manifest.ts b/examples/benchmark-react/bench/build-manifest.ts new file mode 100644 index 000000000000..52d34c2e0acf --- /dev/null +++ b/examples/benchmark-react/bench/build-manifest.ts @@ -0,0 +1,286 @@ +/** + * BuildManifest v1 — provenance for browser GC measurements. + * + * Generated after webpack build. Runner verifies local source digest + artifact + * hashes and that the served `/gc-build-manifest.json` matches before measuring. + */ +import { execSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const BENCH_ROOT = path.resolve(__dirname, '..'); +export const REPO_ROOT = path.resolve(BENCH_ROOT, '../..'); +export const DIST_DIR = path.join(BENCH_ROOT, 'dist'); +export const MANIFEST_FILENAME = 'gc-build-manifest.json'; +export const MANIFEST_PATH = path.join(DIST_DIR, MANIFEST_FILENAME); + +export interface BuildManifestV1 { + schemaVersion: 1; + buildId: string; + commit: string; + dirty: boolean; + sourceDigest: string; + /** relative path from dist/ → sha256 hex */ + artifacts: Record; +} + +function sha256Buffer(buf: Buffer): string { + return createHash('sha256').update(buf).digest('hex'); +} + +function sha256File(filePath: string): string { + return sha256Buffer(fs.readFileSync(filePath)); +} + +function git(cmd: string): string { + try { + return execSync(`git ${cmd}`, { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +} + +/** Walk directory; return absolute file paths sorted. */ +function walkFiles(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + const out: string[] = []; + const stack = [dir]; + while (stack.length) { + const cur = stack.pop()!; + for (const ent of fs.readdirSync(cur, { withFileTypes: true })) { + const p = path.join(cur, ent.name); + if (ent.isDirectory()) { + if ( + ent.name === 'node_modules' || + ent.name === 'dist' || + ent.name === '.git' + ) + continue; + stack.push(p); + } else if (ent.isFile()) { + out.push(p); + } + } + } + return out.sort(); +} + +/** + * Sorted relevant inputs for sourceDigest: + * bundled @data-client package sources + examples/gc-shared + + * benchmark-react src/bench/config/package files. + * Hashes actual file contents (includes dirty/untracked). + */ +export function listSourceInputFiles(): string[] { + const files = new Set(); + // Every @data-client package bundled into dist/, plus shared GC code + for (const dir of [ + 'packages/core/src', + 'packages/normalizr/src', + 'packages/endpoint/src', + 'packages/rest/src', + 'packages/react/src', + 'examples/gc-shared', + ]) { + for (const f of walkFiles(path.join(REPO_ROOT, dir))) files.add(f); + } + const benchRoots = [ + path.join(BENCH_ROOT, 'src'), + path.join(BENCH_ROOT, 'bench'), + ]; + for (const root of benchRoots) { + for (const f of walkFiles(root)) files.add(f); + } + for (const name of [ + 'package.json', + 'tsconfig.json', + 'webpack.config.cjs', + '.babelrc.js', + 'playwright.config.ts', + ]) { + const p = path.join(BENCH_ROOT, name); + if (fs.existsSync(p)) files.add(p); + } + return [...files].sort(); +} + +export function computeSourceDigest(files = listSourceInputFiles()): string { + const h = createHash('sha256'); + for (const abs of files) { + const rel = path.relative(REPO_ROOT, abs).split(path.sep).join('/'); + h.update(rel); + h.update('\0'); + h.update(fs.readFileSync(abs)); + h.update('\0'); + } + return h.digest('hex'); +} + +export function listDistArtifacts(distDir = DIST_DIR): string[] { + return walkFiles(distDir) + .filter(f => path.basename(f) !== MANIFEST_FILENAME) + .sort(); +} + +export function hashDistArtifacts(distDir = DIST_DIR): Record { + const artifacts: Record = {}; + for (const abs of listDistArtifacts(distDir)) { + const rel = path.relative(distDir, abs).split(path.sep).join('/'); + artifacts[rel] = sha256File(abs); + } + // Stable key order + return Object.fromEntries( + Object.keys(artifacts) + .sort() + .map(k => [k, artifacts[k]]), + ); +} + +export function isGitDirty(): boolean { + const status = git('status --porcelain'); + return status.length > 0; +} + +export function gitCommitFull(): string { + return git('rev-parse HEAD') || 'unknown'; +} + +export function computeBuildId(inputs: { + schemaVersion: 1; + commit: string; + dirty: boolean; + sourceDigest: string; + artifacts: Record; +}): string { + const h = createHash('sha256'); + // Canonical BuildManifest v1 inputs (order fixed; artifacts sorted by path) + h.update(`schemaVersion:${inputs.schemaVersion}\n`); + h.update(`commit:${inputs.commit}\n`); + h.update(`dirty:${inputs.dirty ? '1' : '0'}\n`); + h.update(`sourceDigest:${inputs.sourceDigest}\n`); + for (const k of Object.keys(inputs.artifacts).sort()) { + h.update(k); + h.update(':'); + h.update(inputs.artifacts[k]); + h.update('\n'); + } + return h.digest('hex'); +} + +export function writeBuildManifest(distDir = DIST_DIR): BuildManifestV1 { + fs.mkdirSync(distDir, { recursive: true }); + const sourceDigest = computeSourceDigest(); + const artifacts = hashDistArtifacts(distDir); + const commit = gitCommitFull(); + const dirty = isGitDirty(); + const schemaVersion = 1 as const; + const buildId = computeBuildId({ + schemaVersion, + commit, + dirty, + sourceDigest, + artifacts, + }); + const manifest: BuildManifestV1 = { + schemaVersion, + buildId, + commit, + dirty, + sourceDigest, + artifacts, + }; + fs.writeFileSync( + path.join(distDir, MANIFEST_FILENAME), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + return manifest; +} + +export function readBuildManifest( + manifestPath = MANIFEST_PATH, +): BuildManifestV1 { + const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (raw.schemaVersion !== 1) { + throw new Error( + `unsupported build manifest schemaVersion: ${raw.schemaVersion}`, + ); + } + return raw as BuildManifestV1; +} + +export interface ProvenanceVerification { + ok: true; + manifest: BuildManifestV1; +} + +/** + * Verify local source digest + on-disk artifact hashes match the manifest. + * Does not fetch the served URL (caller does that separately). + */ +export function verifyLocalManifest( + manifest: BuildManifestV1, + distDir = DIST_DIR, +): void { + const sourceDigest = computeSourceDigest(); + if (sourceDigest !== manifest.sourceDigest) { + throw new Error( + `stale build: sourceDigest mismatch (manifest ${manifest.sourceDigest.slice(0, 12)}… vs current ${sourceDigest.slice(0, 12)}…). Rebuild with yarn build.`, + ); + } + const artifacts = hashDistArtifacts(distDir); + const manKeys = Object.keys(manifest.artifacts).sort(); + const curKeys = Object.keys(artifacts).sort(); + if (manKeys.join('\n') !== curKeys.join('\n')) { + throw new Error( + 'stale/tampered build: dist artifact set does not match manifest', + ); + } + for (const k of manKeys) { + if (artifacts[k] !== manifest.artifacts[k]) { + throw new Error( + `tampered/stale artifact: ${k} hash mismatch (rebuild required)`, + ); + } + } + const buildId = computeBuildId({ + schemaVersion: 1, + commit: manifest.commit, + dirty: manifest.dirty, + sourceDigest, + artifacts, + }); + if (buildId !== manifest.buildId) { + throw new Error( + 'buildId mismatch after recompute (manifest fields tampered or corrupted?)', + ); + } +} + +/** CLI: write manifest after webpack. */ +async function main() { + if (!fs.existsSync(DIST_DIR)) { + throw new Error(`dist/ missing at ${DIST_DIR}; run webpack build first`); + } + const m = writeBuildManifest(); + process.stderr.write( + `BuildManifest v1 → ${MANIFEST_FILENAME} buildId=${m.buildId.slice(0, 16)}… commit=${m.commit.slice(0, 8)} dirty=${m.dirty}\n`, + ); +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + main().catch(err => { + console.error(err); + process.exit(1); + }); +} diff --git a/examples/benchmark-react/bench/gc-interaction-metrics.test.ts b/examples/benchmark-react/bench/gc-interaction-metrics.test.ts new file mode 100644 index 000000000000..7d901dcd46fc --- /dev/null +++ b/examples/benchmark-react/bench/gc-interaction-metrics.test.ts @@ -0,0 +1,291 @@ +/** + * Deterministic unit tests for GC interaction metrics, stable IDs, and report completeness. + * + * yarn test:gc-metrics + */ +import { buildGCReport } from './gc-report.ts'; +import { + browserGCScenarioId, + computeMaxInputDelayMs, + excessMissedFrames, + frameIntervalsFromTimestamps, + longTaskOverlapsWindow, + parseBrowserGCScenarioId, +} from '../src/data-client/gcInteractionMetrics.ts'; + +function assert(cond: boolean, message: string): asserts cond { + if (!cond) throw new Error(message); +} + +function assertEq(actual: unknown, expected: unknown, label: string) { + assert( + Object.is(actual, expected) || actual === expected, + `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); +} + +function assertClose( + actual: number, + expected: number, + eps: number, + label: string, +) { + assert( + Math.abs(actual - expected) <= eps, + `${label}: expected ≈${expected} (±${eps}), got ${actual}`, + ); +} + +// --- frameIntervalsFromTimestamps --- +assertEq(frameIntervalsFromTimestamps([]).length, 0, 'empty timestamps'); +assertEq(frameIntervalsFromTimestamps([10]).length, 0, 'single timestamp'); +{ + const intervals = frameIntervalsFromTimestamps([0, 16.7, 50.1]); + assertEq(intervals.length, 2, 'two intervals'); + assertClose(intervals[0], 16.7, 1e-9, 'first interval'); + assertClose(intervals[1], 33.4, 1e-9, '2-period gap interval'); +} + +// --- excessMissedFrames (nearest-period) --- +const period = 16.67; +assertEq(excessMissedFrames([period], period), 0, 'nominal 1× interval'); +assertEq( + excessMissedFrames([period * 1.98], period), + 1, + '1.98× must count as 2 periods → 1 missed (floor would wrongly yield 0)', +); +assertEq( + excessMissedFrames([32.5], 16.67), + 1, + '32.5/16.67≈1.95 → round to 2 → 1 missed', +); +assertEq( + excessMissedFrames([period * 0.9, period * 1.05], period), + 0, + 'sub-period jitter around 1× → zero excess', +); +assertEq( + excessMissedFrames([period * 2.9, period], period), + 2, + '2.9× → 3 periods → 2 missed; plus nominal frame', +); +assertEq(excessMissedFrames([period], 0), 0, 'zero displayPeriod safe'); + +// Calibration-scale gap (~45ms busy with ~16.7 period → ~2 missed) +{ + const blockedSpan = frameIntervalsFromTimestamps([ + 0, + 16.7, + 16.7 + 45, + 16.7 + 45 + 16.7, + ]); + assertEq( + excessMissedFrames(blockedSpan, 16.7), + 2, + '45ms blocked span → 2 missed frames over one expected period', + ); + assert( + Math.max(...blockedSpan) >= 16.7 * 1.5, + 'calibration-scale interval exceeds 1.5× display period', + ); +} + +// --- computeMaxInputDelayMs --- +assertEq( + computeMaxInputDelayMs(12, [16.7, 16.7], 16.7), + 12, + 'timer dominates when frames are nominal', +); +assertEq( + computeMaxInputDelayMs(5, [16.7, 40], 16.7), + 40 - 16.7, + 'frame excess dominates when larger than timer', +); +assertEq(computeMaxInputDelayMs(0, [], 16.7), 0, 'empty frames → timer only'); + +// --- longTaskOverlapsWindow --- +assert( + longTaskOverlapsWindow({ startTime: 9, duration: 20 }, 10, 40), + 'entry starting just before windowStart still overlaps', +); +assert( + !longTaskOverlapsWindow({ startTime: 0, duration: 5 }, 10, 40), + 'entry ending before windowStart does not overlap', +); +assert( + !longTaskOverlapsWindow({ startTime: 40, duration: 5 }, 10, 40), + 'entry starting at windowEnd does not overlap', +); +assert( + longTaskOverlapsWindow({ startTime: 15, duration: 10 }, 10, 40), + 'entry fully inside window overlaps', +); + +// --- stable semantic IDs --- +assertEq( + browserGCScenarioId({ + candidateKind: 'endpoint', + pattern: 'unique', + count: 100000, + control: 'gc', + }), + 'browser/endpoint/unique/100000/end-to-end/gc', + 'endpoint unique 100k gc id', +); +assertEq( + browserGCScenarioId({ + candidateKind: 'entity', + pattern: 'duplicate', + count: 1000, + control: 'no-gc', + }), + 'browser/entity/duplicate/1000/end-to-end/no-gc', + 'entity duplicate no-gc id', +); +{ + const id = 'browser/mixed/unique/10000/end-to-end/gc'; + const parsed = parseBrowserGCScenarioId(id); + assertEq(parsed.candidateKind, 'mixed', 'parse kind'); + assertEq(parsed.pattern, 'unique', 'parse pattern'); + assertEq(parsed.count, 10000, 'parse count'); + assertEq(parsed.control, 'gc', 'parse control'); + assertEq(parsed.mode, 'end-to-end', 'parse mode'); + assertEq(browserGCScenarioId(parsed), id, 'round-trip id'); +} +let threw = false; +try { + browserGCScenarioId({ + candidateKind: 'endpoint', + pattern: 'duplicate', + count: 1000, + control: 'gc', + }); +} catch { + threw = true; +} +assert(threw, 'duplicate+endpoint must throw'); + +// --- report complete flag --- +{ + const provenance = { + schemaVersion: 1 as const, + buildId: 'abc', + commit: 'deadbeef', + dirty: false, + sourceDigest: 'digest', + artifacts: {}, + servedManifestBuildId: 'abc', + }; + const completeReport = buildGCReport({ + scenarios: [], + samplesPerScenario: 5, + filter: null, + browserVersion: '1', + headless: true, + requestedScenarios: 0, + requestedSamples: 0, + completedScenarios: 0, + completedSamples: 0, + failures: [], + provenance, + }); + assert(completeReport.complete === true, 'empty requested → complete'); + + const incomplete = buildGCReport({ + scenarios: [], + samplesPerScenario: 5, + filter: null, + browserVersion: '1', + headless: true, + requestedScenarios: 1, + requestedSamples: 5, + completedScenarios: 0, + completedSamples: 2, + failures: [ + { + scenarioId: 'browser/endpoint/unique/100000/end-to-end/gc', + sampleIndex: 2, + error: 'boom', + }, + ], + provenance, + }); + assert(incomplete.complete === false, 'failures → complete:false'); + assertEq(incomplete.failures.length, 1, 'failures recorded'); + assertEq(incomplete.completedSamples, 2, 'partial samples counted'); + assertEq( + incomplete.provenance.buildId, + 'abc', + 'provenance from verified manifest not HEAD', + ); +} + +/** Throwing work must reject promptly and leave no active rAF polyfill chain. */ +async function testThrowingWorkRejectsPromptly() { + const { runChromiumInteractionProbe } = + await import('../src/data-client/gcInteractionProbe.ts'); + + const pendingRaf = new Set>(); + const g = globalThis as unknown as { + requestAnimationFrame?: (cb: (t: number) => void) => number; + cancelAnimationFrame?: (id: number) => void; + }; + const prevRaf = g.requestAnimationFrame; + const prevCancel = g.cancelAnimationFrame; + + g.requestAnimationFrame = (cb: (t: number) => void) => { + const handle = setTimeout(() => { + pendingRaf.delete(handle); + cb(performance.now()); + }, 0); + pendingRaf.add(handle); + return handle as unknown as number; + }; + g.cancelAnimationFrame = (id: number) => { + const handle = id as unknown as ReturnType; + clearTimeout(handle); + pendingRaf.delete(handle); + }; + + try { + const t0 = performance.now(); + let rejected: unknown; + try { + await runChromiumInteractionProbe({ + displayPeriodMs: 16.7, + work: () => { + throw new Error('synthetic work boom'); + }, + }); + } catch (err) { + rejected = err; + } + const elapsed = performance.now() - t0; + assert(rejected instanceof Error, 'throwing work must reject'); + assert( + (rejected as Error).message === 'synthetic work boom', + 'rejection carries work error', + ); + assert(elapsed < 500, `must reject promptly (elapsed ${elapsed}ms)`); + + await new Promise(r => setTimeout(r, 30)); + assert( + pendingRaf.size === 0, + `no active rAF chain after reject (pending=${pendingRaf.size})`, + ); + } finally { + if (prevRaf) g.requestAnimationFrame = prevRaf; + else delete g.requestAnimationFrame; + if (prevCancel) g.cancelAnimationFrame = prevCancel; + else delete g.cancelAnimationFrame; + } +} + +testThrowingWorkRejectsPromptly() + .then(() => { + process.stderr.write('gc-interaction-metrics: all assertions passed\n'); + }) + .catch(err => { + console.error(err); + process.exit(1); + }); diff --git a/examples/benchmark-react/bench/gc-provenance.test.ts b/examples/benchmark-react/bench/gc-provenance.test.ts new file mode 100644 index 000000000000..5d774b2ac95e --- /dev/null +++ b/examples/benchmark-react/bench/gc-provenance.test.ts @@ -0,0 +1,148 @@ +/** + * Provenance tamper tests: artifact / commit / dirty / buildId must fail verify. + * + * Verifies the manifest `yarn build` already wrote against the existing dist. + * Does not call writeBuildManifest(), so a stale or tampered bundle cannot be + * re-stamped into a passing run. Tamper cases restore the original bytes. + * + * yarn test:gc-provenance + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { + DIST_DIR, + MANIFEST_PATH, + computeBuildId, + readBuildManifest, + verifyLocalManifest, + type BuildManifestV1, +} from './build-manifest.ts'; + +function assert(cond: boolean, msg: string): asserts cond { + if (!cond) throw new Error(msg); +} + +function expectVerifyFail(label: string, mutate: (m: BuildManifestV1) => void) { + const original = fs.readFileSync(MANIFEST_PATH); + try { + const m = readBuildManifest(); + mutate(m); + fs.writeFileSync(MANIFEST_PATH, `${JSON.stringify(m, null, 2)}\n`); + let failed = false; + try { + verifyLocalManifest(readBuildManifest()); + } catch (err) { + failed = true; + process.stderr.write( + `provenance: ${label} rejected: ${err instanceof Error ? err.message : err}\n`, + ); + } + assert(failed, `${label}: expected verifyLocalManifest to fail`); + } finally { + fs.writeFileSync(MANIFEST_PATH, original); + } +} + +if ( + !fs.existsSync(DIST_DIR) || + !fs.existsSync(path.join(DIST_DIR, 'data-client.js')) +) { + throw new Error('dist/ incomplete — run yarn build first'); +} +if (!fs.existsSync(MANIFEST_PATH)) { + throw new Error( + `missing manifest at ${MANIFEST_PATH} — run yarn build; this test does not write one`, + ); +} + +const originalManifestBytes = fs.readFileSync(MANIFEST_PATH); +const target = path.join(DIST_DIR, 'data-client.js'); +const originalArtifact = fs.readFileSync(target); + +try { + let manifest: BuildManifestV1; + try { + manifest = readBuildManifest(); + verifyLocalManifest(manifest); + } catch (err) { + throw new Error( + `existing manifest does not match dist: ${err instanceof Error ? err.message : err}`, + ); + } + process.stderr.write('provenance: clean verify OK\n'); + + // Canonical buildId includes schemaVersion, commit, dirty, sourceDigest, artifacts + { + const expected = computeBuildId({ + schemaVersion: 1, + commit: manifest.commit, + dirty: manifest.dirty, + sourceDigest: manifest.sourceDigest, + artifacts: manifest.artifacts, + }); + assert( + expected === manifest.buildId, + 'stored buildId must match canonical digest of manifest fields', + ); + const flippedDirty = computeBuildId({ + schemaVersion: 1, + commit: manifest.commit, + dirty: !manifest.dirty, + sourceDigest: manifest.sourceDigest, + artifacts: manifest.artifacts, + }); + assert(flippedDirty !== manifest.buildId, 'dirty bit must affect buildId'); + const otherCommit = computeBuildId({ + schemaVersion: 1, + commit: `${manifest.commit}dead`, + dirty: manifest.dirty, + sourceDigest: manifest.sourceDigest, + artifacts: manifest.artifacts, + }); + assert(otherCommit !== manifest.buildId, 'commit must affect buildId'); + } + + // Artifact content tamper. Restore the original bytes; do not rewrite the manifest. + try { + fs.writeFileSync( + target, + Buffer.concat([originalArtifact, Buffer.from('\n/* tamper */\n')]), + ); + let failed = false; + try { + verifyLocalManifest(readBuildManifest()); + } catch (err) { + failed = true; + process.stderr.write( + `provenance: artifact tamper rejected: ${err instanceof Error ? err.message : err}\n`, + ); + } + assert(failed, 'tampered artifact must fail verifyLocalManifest'); + } finally { + fs.writeFileSync(target, originalArtifact); + } + verifyLocalManifest(readBuildManifest()); + + // Manifest field tampers (buildId left stale → mismatch) + expectVerifyFail('commit field tamper', m => { + m.commit = `${m.commit}0`; + }); + expectVerifyFail('dirty field tamper', m => { + m.dirty = !m.dirty; + }); + expectVerifyFail('buildId field tamper', m => { + m.buildId = '0'.repeat(64); + }); + + verifyLocalManifest(readBuildManifest()); + assert( + fs.readFileSync(MANIFEST_PATH).equals(originalManifestBytes), + 'tamper cases must not replace the real manifest', + ); + assert(fs.existsSync(MANIFEST_PATH), 'manifest exists'); + process.stderr.write('gc-provenance: all assertions passed\n'); +} finally { + fs.writeFileSync(MANIFEST_PATH, originalManifestBytes); + fs.writeFileSync(target, originalArtifact); +} diff --git a/examples/benchmark-react/bench/gc-report.ts b/examples/benchmark-react/bench/gc-report.ts new file mode 100644 index 000000000000..a172e20b233d --- /dev/null +++ b/examples/benchmark-react/bench/gc-report.ts @@ -0,0 +1,240 @@ +/** + * Aggregate schemaVersion 1 detailed GC measurement report for the browser harness. + * Raw Chromium traces (BENCH_TRACE) remain separate diagnostic artifacts. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { BuildManifestV1 } from './build-manifest.js'; +import { summarizeNumbers } from '../../gc-shared/stats.js'; +import type { NumberSummary } from '../../gc-shared/stats.js'; +import { browserGCScenarioId } from '../src/data-client/gcInteractionMetrics.js'; +import type { + GCBrowserMeasurement, + GCScenarioConfig, +} from '../src/shared/types.js'; + +export interface GCSampleResult extends GCBrowserMeasurement { + heapBeforeBytes?: number; + heapAfterBytes?: number; + heapDeltaBytes?: number; +} + +export type { NumberSummary }; +export { summarizeNumbers }; + +export interface GCScenarioReport { + /** Stable id: browser/{kind}/{pattern}/{count}/end-to-end/{control} */ + id: string; + platform: 'browser'; + candidateKind: GCScenarioConfig['candidateKind']; + pattern: GCScenarioConfig['pattern']; + count: number; + mode: 'end-to-end'; + control: GCScenarioConfig['control']; + samples: GCSampleResult[]; + summary: Record; +} + +export interface GCFailureRecord { + scenarioId: string; + sampleIndex?: number; + error: string; +} + +export interface GCMeasurementReport { + schemaVersion: 1; + /** false when any requested sample/scenario failed */ + complete: boolean; + requestedScenarios: number; + requestedSamples: number; + completedScenarios: number; + completedSamples: number; + failures: GCFailureRecord[]; + units: Record; + memorySemantics: { + model: 'keep-store-drop-observer'; + description: string; + }; + /** Verified BuildManifest provenance — never live HEAD */ + provenance: { + buildId: string; + commit: string; + dirty: boolean; + sourceDigest: string; + servedManifestBuildId: string; + }; + build: { + commit: string; + label: string; + }; + environment: { + runtime: 'chromium'; + browserVersion: string; + headless: boolean; + os: string; + arch: string; + cpuModel: string; + cpus: number; + platform: NodeJS.Platform; + nodeVersion: string; + }; + config: { + samplesPerScenario: number; + filter: string | null; + }; + scenarios: GCScenarioReport[]; +} + +export function summarizeGCSamples( + samples: GCSampleResult[], +): Record { + const summary: Record = { + totalMs: summarizeNumbers(samples.map(s => s.totalMs)), + actionCount: summarizeNumbers(samples.map(s => s.actionCount)), + queueEntries: summarizeNumbers(samples.map(s => s.queueEntries)), + uniqueTargets: summarizeNumbers(samples.map(s => s.uniqueTargets)), + actionTargetCount: summarizeNumbers(samples.map(s => s.actionTargetCount)), + deletionCount: summarizeNumbers(samples.map(s => s.deletionCount)), + timerDelayMs: summarizeNumbers(samples.map(s => s.timerDelayMs)), + displayPeriodMs: summarizeNumbers(samples.map(s => s.displayPeriodMs)), + missedFrames: summarizeNumbers(samples.map(s => s.missedFrames)), + maxInputDelayMs: summarizeNumbers(samples.map(s => s.maxInputDelayMs)), + longTaskCount: summarizeNumbers(samples.map(s => s.longTaskCount)), + longTaskTotalMs: summarizeNumbers(samples.map(s => s.longTaskTotalMs)), + }; + const slices = samples.flatMap(s => s.sliceDurationsMs ?? []); + if (slices.length) { + summary.sliceDurationsMs = summarizeNumbers(slices); + } + const frameIntervals = samples.flatMap(s => s.frameIntervalsMs ?? []); + if (frameIntervals.length) { + summary.frameIntervalsMs = summarizeNumbers(frameIntervals); + } + const heaps = samples.filter(s => s.heapBeforeBytes != null); + if (heaps.length) { + summary.heapBeforeBytes = summarizeNumbers( + heaps.map(s => s.heapBeforeBytes!), + ); + summary.heapAfterBytes = summarizeNumbers( + heaps.map(s => s.heapAfterBytes!), + ); + summary.heapDeltaBytes = summarizeNumbers( + heaps.map(s => s.heapDeltaBytes!), + ); + } + return summary; +} + +export function cpuModel(): string { + return os.cpus()[0]?.model ?? 'unknown'; +} + +export function defaultGCReportPath(): string { + return path.resolve( + process.env.BENCH_GC_OUTPUT ?? 'gc-measurement-output.json', + ); +} + +export function writeGCReport( + report: GCMeasurementReport, + outputPath: string = defaultGCReportPath(), +): void { + fs.writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`); + process.stderr.write( + `GC detailed report → ${outputPath} (complete=${report.complete})\n`, + ); +} + +export function scenarioReportFromConfig( + config: GCScenarioConfig, + samples: GCSampleResult[], +): GCScenarioReport { + const id = browserGCScenarioId(config); + return { + id, + platform: 'browser', + candidateKind: config.candidateKind, + pattern: config.pattern, + count: config.count, + mode: 'end-to-end', + control: config.control, + samples, + summary: summarizeGCSamples(samples), + }; +} + +export function buildGCReport(opts: { + scenarios: GCScenarioReport[]; + samplesPerScenario: number; + filter: string | null; + browserVersion: string; + headless: boolean; + requestedScenarios: number; + requestedSamples: number; + completedScenarios: number; + completedSamples: number; + failures: GCFailureRecord[]; + provenance: BuildManifestV1 & { servedManifestBuildId: string }; +}): GCMeasurementReport { + const complete = + opts.failures.length === 0 && + opts.completedScenarios === opts.requestedScenarios && + opts.completedSamples === opts.requestedSamples; + + return { + schemaVersion: 1, + complete, + requestedScenarios: opts.requestedScenarios, + requestedSamples: opts.requestedSamples, + completedScenarios: opts.completedScenarios, + completedSamples: opts.completedSamples, + failures: opts.failures, + units: { + totalMs: 'milliseconds', + sliceDurationsMs: 'milliseconds', + timerDelayMs: 'milliseconds', + frameIntervalsMs: 'milliseconds', + displayPeriodMs: 'milliseconds', + maxInputDelayMs: 'milliseconds', + longTaskTotalMs: 'milliseconds', + missedFrames: 'frames', + heapBeforeBytes: 'bytes', + heapAfterBytes: 'bytes', + heapDeltaBytes: 'bytes', + }, + memorySemantics: { + model: 'keep-store-drop-observer', + description: + 'heapBefore is taken after prepareGCScenario with forced Chromium engine GC. After runGCScenario (which validates and releases captured GC action arrays), settle + forced engine GC yields heapAfter while the live store remains. Engine GC is never inside interaction timing. Compare gc vs no-gc for retained-cache signal.', + }, + provenance: { + buildId: opts.provenance.buildId, + commit: opts.provenance.commit, + dirty: opts.provenance.dirty, + sourceDigest: opts.provenance.sourceDigest, + servedManifestBuildId: opts.provenance.servedManifestBuildId, + }, + build: { + commit: opts.provenance.commit, + label: 'examples/benchmark-react GC monolithic browser baseline', + }, + environment: { + runtime: 'chromium', + browserVersion: opts.browserVersion, + headless: opts.headless, + os: `${os.type()} ${os.release()}`, + arch: os.arch(), + cpuModel: cpuModel(), + cpus: os.cpus().length, + platform: process.platform, + nodeVersion: process.version, + }, + config: { + samplesPerScenario: opts.samplesPerScenario, + filter: opts.filter, + }, + scenarios: opts.scenarios, + }; +} diff --git a/examples/benchmark-react/bench/gc-runner.ts b/examples/benchmark-react/bench/gc-runner.ts new file mode 100644 index 000000000000..8237da1bdeef --- /dev/null +++ b/examples/benchmark-react/bench/gc-runner.ts @@ -0,0 +1,329 @@ +/// +/** + * Browser GC runner (data-client only): isolated cache-GC interaction + * measurement via the lazy gc-browser-harness chunk. Separate from the + * hot-path runner (runner.ts); shares page/browser plumbing (browser.ts) and + * the GC protocol/fixture (examples/gc-shared) with the Node/Android harnesses. + * + * yarn bench:gc [--samples N] [--scenario FILTER] + * + * --samples / BENCH_GC_SAMPLES samples per scenario (default 5) + * --scenario / BENCH_SCENARIO slash-bounded segments of the stable id + * browser/{kind}/{pattern}/{count}/end-to-end/{control}, or ^prefix + * (e.g. `100000`, `entity/unique/100000`, `/1000/`) + * + * Writes the detailed report (BENCH_GC_OUTPUT, default + * gc-measurement-output.json); exits 1 when the report is incomplete. + */ +import * as fs from 'node:fs'; +import type { Browser, CDPSession, Page } from 'playwright'; + +import { BASE_URL, launchBenchChromium, setupBenchPage } from './browser.js'; +import { + MANIFEST_FILENAME, + MANIFEST_PATH, + readBuildManifest, + verifyLocalManifest, + type BuildManifestV1, +} from './build-manifest.js'; +import { + buildGCReport, + scenarioReportFromConfig, + writeGCReport, + type GCFailureRecord, + type GCSampleResult, + type GCScenarioReport, +} from './gc-report.js'; +import { collectHeapUsed } from './memory.js'; +import type { ScenarioDescriptor } from '../../gc-shared/protocol.js'; +import { listScenarios } from '../../gc-shared/protocol.js'; +import type { + GCBrowserMeasurement, + GCScenarioConfig, +} from '../src/shared/types.js'; + +type GCScenarioDescriptor = ScenarioDescriptor & GCScenarioConfig; + +function parseArgs(): { samples: number; filter: string | null } { + const argv = process.argv.slice(2); + const get = (flag: string, envVar: string): string | undefined => { + const idx = argv.indexOf(flag); + if (idx !== -1 && idx + 1 < argv.length) return argv[idx + 1]; + return process.env[envVar] || undefined; + }; + + const samplesRaw = get('--samples', 'BENCH_GC_SAMPLES') ?? '5'; + const samples = Number.parseInt(samplesRaw, 10); + if (!Number.isFinite(samples) || !Number.isInteger(samples) || samples < 1) { + throw new Error( + `invalid --samples / BENCH_GC_SAMPLES=${JSON.stringify(samplesRaw)}; expected integer ≥ 1`, + ); + } + return { samples, filter: get('--scenario', 'BENCH_SCENARIO') ?? null }; +} + +async function settlePage(page: Page): Promise { + await page.evaluate( + () => + new Promise(r => + requestAnimationFrame(() => requestAnimationFrame(() => r())), + ), + ); + await page.waitForTimeout(50); +} + +async function verifyGCBuildProvenance( + baseUrl: string, +): Promise { + if (!fs.existsSync(MANIFEST_PATH)) { + throw new Error( + `Missing ${MANIFEST_FILENAME}. Run yarn build (writes BuildManifest v1 after webpack).`, + ); + } + const local = readBuildManifest(); + verifyLocalManifest(local); + + const servedUrl = `${baseUrl.replace(/\/$/, '')}/${MANIFEST_FILENAME}`; + const res = await fetch(servedUrl); + if (!res.ok) { + throw new Error( + `Failed to fetch served ${MANIFEST_FILENAME} from ${servedUrl}: ${res.status}`, + ); + } + const served = (await res.json()) as BuildManifestV1; + if (served.schemaVersion !== 1) { + throw new Error(`served manifest schemaVersion ${served.schemaVersion}`); + } + if (served.buildId !== local.buildId) { + throw new Error( + `served manifest buildId mismatch (local ${local.buildId.slice(0, 12)}… vs served ${served.buildId.slice(0, 12)}…). Restart preview after rebuild.`, + ); + } + process.stderr.write( + `GC provenance OK buildId=${local.buildId.slice(0, 16)}… commit=${local.commit.slice(0, 8)} dirty=${local.dirty}\n`, + ); + return { ...local, servedManifestBuildId: served.buildId }; +} + +/** + * Dedicated GC phase (not the generic convergent/update path). + * Per sample: prepare → engine GC + heapBefore → run → settle → engine GC + + * heapAfter (store live) → dispose. Never force engine GC inside interaction timing. + * Failures are accumulated; report is always written with complete true/false; + * returns { reports, complete } and caller must exit nonzero when incomplete. + */ +async function runGCScenarioSample( + page: Page, + bench: any, + config: GCScenarioConfig, + cdp: CDPSession, +): Promise { + await (bench as any).evaluate(async (api: any, cfg: GCScenarioConfig) => { + if (!api.prepareGCScenario) { + throw new Error('prepareGCScenario not available'); + } + await api.prepareGCScenario(cfg); + }, config); + + // Forced engine GC + heapBefore (outside interaction timing) + const heapBeforeBytes = await collectHeapUsed(cdp); + + const measurement: GCBrowserMeasurement = await (bench as any).evaluate( + async (api: any) => { + if (!api.runGCScenario) { + throw new Error('runGCScenario not available'); + } + return api.runGCScenario(); + }, + ); + + await settlePage(page); + + // Forced engine GC + heapAfter while store remains live + const heapAfterBytes = await collectHeapUsed(cdp); + + await (bench as any).evaluate((api: any) => { + if (api.disposeGCScenario) api.disposeGCScenario(); + }); + + return { + ...measurement, + heapBeforeBytes, + heapAfterBytes, + heapDeltaBytes: heapAfterBytes - heapBeforeBytes, + }; +} + +async function runGCPhase( + browser: Browser, + scenarios: GCScenarioDescriptor[], + sampleCount: number, + scenarioFilter: string | null, + provenance: BuildManifestV1 & { servedManifestBuildId: string }, +): Promise<{ reports: GCScenarioReport[]; complete: boolean }> { + const reports: GCScenarioReport[] = []; + const failures: GCFailureRecord[] = []; + const requestedScenarios = scenarios.length; + const requestedSamples = scenarios.length * sampleCount; + let completedSamples = 0; + + if (scenarios.length === 0) { + return { reports, complete: true }; + } + + process.stderr.write( + `\n── GC (${scenarios.length} scenarios, ${sampleCount} samples each) ──\n`, + ); + + const context = await browser.newContext(); + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + try { + await cdp.send('Performance.enable'); + } catch { + // best-effort + } + + for (const { + id: scenarioId, + candidateKind, + pattern, + count, + control, + } of scenarios) { + const config: GCScenarioConfig = { candidateKind, pattern, count, control }; + const scenarioSamples: GCSampleResult[] = []; + let scenarioFailed = false; + + process.stderr.write(` ${scenarioId}...\n`); + + for (let i = 0; i < sampleCount; i++) { + try { + // Fresh page load per sample isolates heap between samples + const { bench } = await setupBenchPage(page, 'data-client', {}, false); + const sample = await runGCScenarioSample(page, bench, config, cdp); + scenarioSamples.push(sample); + completedSamples++; + await bench.dispose(); + } catch (err) { + scenarioFailed = true; + const message = err instanceof Error ? err.message : String(err); + failures.push({ + scenarioId, + sampleIndex: i, + error: message, + }); + console.error(` ${scenarioId} sample ${i} FAILED:`, message); + try { + await page.evaluate(() => { + window.__BENCH__?.disposeGCScenario?.(); + }); + } catch { + // ignore cleanup failures + } + break; + } + } + + if (!scenarioFailed && scenarioSamples.length === sampleCount) { + const report = scenarioReportFromConfig(config, scenarioSamples); + const { summary } = report; + const medianTotal = summary.totalMs?.median ?? 0; + const maxFrame = + summary.frameIntervalsMs?.max ?? summary.frameIntervalsMs?.median; + process.stderr.write( + ` median totalMs=${medianTotal.toFixed(3)} ms` + + (summary.timerDelayMs ? + ` timerDelay=${summary.timerDelayMs.median.toFixed(2)} ms` + : '') + + (summary.displayPeriodMs ? + ` displayPeriod=${summary.displayPeriodMs.median.toFixed(2)} ms` + : '') + + (maxFrame != null ? + ` frameIntervalMax=${maxFrame.toFixed(2)} ms` + : '') + + (summary.maxInputDelayMs ? + ` maxInputDelay=${summary.maxInputDelayMs.median.toFixed(2)} ms` + : '') + + (summary.missedFrames != null ? + ` missedFrames=${summary.missedFrames.median}` + : '') + + (summary.longTaskCount ? + ` longTasks=${summary.longTaskCount.median}` + + (summary.longTaskTotalMs ? + `/${summary.longTaskTotalMs.median.toFixed(2)} ms` + : '') + : '') + + (summary.heapDeltaBytes ? + ` heapΔ=${Math.round(summary.heapDeltaBytes.median)} B` + : '') + + `\n`, + ); + reports.push(report); + } + } + + await cdp.detach().catch(() => {}); + await context.close(); + + const version = browser.version(); + const report = buildGCReport({ + scenarios: reports, + samplesPerScenario: sampleCount, + filter: scenarioFilter, + browserVersion: version, + headless: true, + requestedScenarios, + requestedSamples, + completedScenarios: reports.length, + completedSamples, + failures, + provenance, + }); + writeGCReport(report); + + if (!report.complete) { + process.stderr.write( + `GC phase incomplete: ${failures.length} failure(s), completed ${completedSamples}/${requestedSamples} samples\n`, + ); + } + + return { reports, complete: report.complete }; +} + +async function main() { + const { samples, filter } = parseArgs(); + const scenarios = listScenarios('browser', { + filter: filter ?? undefined, + }) as GCScenarioDescriptor[]; + if (scenarios.length === 0) { + process.stderr.write( + `No GC scenarios matched --scenario ${JSON.stringify(filter)}.\n`, + ); + process.exit(1); + } + + // Fail fast on a stale build before launching the browser + const provenance = await verifyGCBuildProvenance(BASE_URL); + + const { browser, closeBenchBrowser } = await launchBenchChromium(); + let complete = false; + try { + ({ complete } = await runGCPhase( + browser, + scenarios, + samples, + filter, + provenance, + )); + } finally { + await closeBenchBrowser(); + } + + if (!complete) process.exitCode = 1; +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 56dec71ce177..bd8fdb1a6229 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -1,15 +1,13 @@ /// -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { chromium } from 'playwright'; -import type { - Browser, - BrowserServer, - CDPSession, - Locator, - Page, -} from 'playwright'; +import type { Browser, CDPSession, Locator, Page } from 'playwright'; +import { + BENCH_V8_DEOPT, + V8_LOG_DIR, + launchBenchChromium, + reportV8Logs, + setupBenchPage, +} from './browser.js'; import { collectMeasures, getMeasureDuration } from './measure.js'; import { collectHeapUsed } from './memory.js'; import { formatReport, type BenchmarkResult } from './report.js'; @@ -19,7 +17,6 @@ import { RUN_CONFIG, CONVERGENT_CONFIG, ACTION_GROUPS, - NETWORK_SIM_CONFIG, } from './scenarios.js'; import type { ConvergentProfile } from './scenarios.js'; import { computeStats, isConverged } from './stats.js'; @@ -126,6 +123,7 @@ function filterScenarios(scenarios: Scenario[]): { filtered = filtered.filter(s => resolvedActions.has(s.action)); } + // Substring of the display name (the GC runner uses stable-id segments instead) if (scenarioFilter) { filtered = filtered.filter(s => s.name.includes(scenarioFilter)); } @@ -136,22 +134,21 @@ function filterScenarios(scenarios: Scenario[]): { !s.onlyLibs?.length || libraries.every(lib => s.onlyLibs!.includes(lib)), ); - return { filtered, libraries, networkSim, opsPerRound }; + return { + filtered, + libraries, + networkSim, + opsPerRound, + }; } // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- -const BASE_URL = - process.env.BENCH_BASE_URL ?? - `http://localhost:${process.env.BENCH_PORT ?? '5173'}`; const BENCH_LABEL = process.env.BENCH_LABEL ? ` [${process.env.BENCH_LABEL}]` : ''; const USE_TRACE = process.env.BENCH_TRACE === 'true'; -const BENCH_V8_TRACE = process.env.BENCH_V8_TRACE === 'true'; -const BENCH_V8_DEOPT = process.env.BENCH_V8_DEOPT === 'true'; -const V8_LOG_DIR = path.resolve('v8-logs'); const MEMORY_WARMUP = 1; const MEMORY_MEASUREMENTS = 3; @@ -207,46 +204,6 @@ function classifyAction(scenario: Scenario): { return { isMountLike, isUpdate: !isMountLike }; } -async function setupBenchPage( - page: Page, - lib: string, - scenario: Scenario, - networkSim: boolean, -): Promise<{ harness: Locator; bench: any }> { - await page.goto(`${BASE_URL}/${lib}/`, { - waitUntil: 'networkidle', - timeout: 120000, - }); - await page.waitForSelector('[data-app-ready]', { - timeout: 120000, - state: 'attached', - }); - - const harness = page.locator('[data-bench-harness]'); - await harness.waitFor({ state: 'attached' }); - - const bench = await page.evaluateHandle('window.__BENCH__'); - if (await bench.evaluate(b => b == null)) - throw new Error('window.__BENCH__ not found'); - - if (networkSim) { - await (bench as any).evaluate( - (api: any, cfg: { baseLatencyMs: number; recordsPerMs: number }) => - api.setNetworkSim(cfg), - NETWORK_SIM_CONFIG, - ); - } - - if (scenario.renderLimit != null) { - await (bench as any).evaluate( - (api: any, n: number) => api.setRenderLimit(n), - scenario.renderLimit, - ); - } - - return { harness, bench }; -} - async function runPreMount( page: Page, harness: Locator, @@ -579,11 +536,15 @@ async function runScenarioConvergent( if (cdp && subIdx > 0 && subIdx % CONVERGENT_GC_INTERVAL === 0) { try { await cdp.send('HeapProfiler.collectGarbage'); - } catch {} + } catch { + // best-effort + } await page.waitForTimeout(30); try { await cdp.send('HeapProfiler.collectGarbage'); - } catch {} + } catch { + // best-effort + } await page.waitForTimeout(50); } @@ -664,99 +625,6 @@ function shuffle(arr: T[]): T[] { return out; } -/** Chromium from `launch()` does not expose `process()`; use `launchServer` when piping V8 trace output. */ -async function launchBenchChromium(): Promise<{ - browser: Browser; - closeBenchBrowser: () => Promise; -}> { - const launchOpts = { - headless: true, - args: buildLaunchArgs(), - }; - - if (BENCH_V8_TRACE) { - const server: BrowserServer = await chromium.launchServer(launchOpts); - let v8TraceStream: fs.WriteStream | undefined; - const proc = server.process(); - if (proc?.stderr ?? proc?.stdout) { - v8TraceStream = fs.createWriteStream('v8-trace.log'); - proc.stderr?.pipe(v8TraceStream, { end: false }); - proc.stdout?.pipe(v8TraceStream, { end: false }); - process.stderr.write( - 'V8 trace output → v8-trace.log (root browser process stderr/stdout)\n', - ); - } else { - process.stderr.write( - 'Warning: BENCH_V8_TRACE but browser server process streams unavailable; v8-trace.log may be empty.\n', - ); - } - const browser = await chromium.connect({ wsEndpoint: server.wsEndpoint() }); - return { - browser, - closeBenchBrowser: async () => { - await browser.close(); - await server.close(); - if (v8TraceStream) { - v8TraceStream.end(); - process.stderr.write( - '\nV8 opt/deopt trace written to v8-trace.log\n', - ); - } - }, - }; - } - - const browser = await chromium.launch(launchOpts); - return { - browser, - closeBenchBrowser: () => browser.close(), - }; -} - -function buildLaunchArgs(): string[] { - const args = [ - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--disable-backgrounding-occluded-windows', - '--disable-hang-monitor', - ]; - const jsFlags: string[] = []; - if (BENCH_V8_TRACE) { - jsFlags.push('--trace-opt', '--trace-deopt'); - } - if (BENCH_V8_DEOPT) { - fs.rmSync(V8_LOG_DIR, { recursive: true, force: true }); - fs.mkdirSync(V8_LOG_DIR, { recursive: true }); - jsFlags.push('--prof', `--logfile=${V8_LOG_DIR}/v8-%p.log`); - } - if (jsFlags.length > 0) args.push(`--js-flags=${jsFlags.join(' ')}`); - return args; -} - -function reportV8Logs(): void { - if (!BENCH_V8_DEOPT) return; - try { - const logs = fs.readdirSync(V8_LOG_DIR).filter(f => f.endsWith('.log')); - if (logs.length === 0) return; - process.stderr.write(`\nV8 profiling logs written to ${V8_LOG_DIR}/:\n`); - for (const log of logs) { - const size = fs.statSync(path.join(V8_LOG_DIR, log)).size; - process.stderr.write(` ${log} (${(size / 1024).toFixed(1)} KB)\n`); - } - const largest = logs.reduce((a, b) => { - const sa = fs.statSync(path.join(V8_LOG_DIR, a)).size; - const sb = fs.statSync(path.join(V8_LOG_DIR, b)).size; - return sa >= sb ? a : b; - }); - process.stderr.write( - `\nProcess the renderer log (typically the largest file) with:\n` + - ` node --prof-process ${V8_LOG_DIR}/${largest}\n\n`, - ); - } catch { - // best-effort reporting - } -} - function scenarioUnit(scenario: Scenario): string { if (isRefStabilityScenario(scenario)) return 'count'; if (scenario.resultMetric === 'heapDelta') return 'bytes'; @@ -811,11 +679,15 @@ async function runRound( // Double-GC before each scenario to reduce variance from prior allocations try { await cdp.send('HeapProfiler.collectGarbage'); - } catch {} + } catch { + // best-effort + } await page.waitForTimeout(100); try { await cdp.send('HeapProfiler.collectGarbage'); - } catch {} + } catch { + // best-effort + } await page.waitForTimeout(400); done++; @@ -937,11 +809,15 @@ async function main() { for (const scenario of libScenarios) { try { await cdp.send('HeapProfiler.collectGarbage'); - } catch {} + } catch { + // best-effort + } await page.waitForTimeout(100); try { await cdp.send('HeapProfiler.collectGarbage'); - } catch {} + } catch { + // best-effort + } await page.waitForTimeout(400); process.stderr.write(` ${scenario.name}...\n`); diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index 9e1298142ddc..361282145452 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -220,14 +220,14 @@ export const LIBRARIES = [ 'baseline', ] as const; -export const SCENARIOS: Scenario[] = LIBRARIES.flatMap(lib => - BASE_SCENARIOS.filter( - base => !base.onlyLibs || base.onlyLibs.includes(lib), - ).map( - ({ nameSuffix, onlyLibs, ...rest }): Scenario => ({ +export const SCENARIOS: Scenario[] = [ + ...LIBRARIES.flatMap(lib => + BASE_SCENARIOS.filter( + base => !base.onlyLibs || base.onlyLibs.includes(lib), + ).map(({ nameSuffix, onlyLibs, ...rest }): Scenario => ({ name: `${lib}: ${nameSuffix}`, ...rest, ...(onlyLibs ? { onlyLibs: [...onlyLibs] } : {}), - }), + })), ), -); +]; diff --git a/examples/benchmark-react/bench/validate.ts b/examples/benchmark-react/bench/validate.ts index 3006c11a896c..7d13796b0eae 100644 --- a/examples/benchmark-react/bench/validate.ts +++ b/examples/benchmark-react/bench/validate.ts @@ -534,6 +534,188 @@ test('listDetailSwitch completes with correct DOM transitions', async (page, lib ); }); +test( + 'GC frame probe calibration: synthetic block spans pending frames', + async (page, lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.calibrateGCFrameProbe === 'function', + )) + ) + return; + + const result = await page.evaluate(async () => { + return window.__BENCH__!.calibrateGCFrameProbe!(45); + }); + + assert( + result.blockMs >= 40 && result.blockMs <= 50, + lib, + 'gc calibrate blockMs', + `expected 40–50ms block, got ${result.blockMs}`, + ); + assert( + result.totalMs >= 35, + lib, + 'gc calibrate totalMs', + `synthetic block should be timed (~45ms), got ${result.totalMs}`, + ); + assert( + result.spannedPendingFrame === true, + lib, + 'gc calibrate spannedPendingFrame', + `expected blocking to span pending frame (missedFrames=${result.missedFrames} frameIntervalMax=${result.frameIntervalMax} displayPeriod=${result.displayPeriodMs})`, + ); + assert( + result.frameIntervalMax >= result.displayPeriodMs * 1.5 || + result.missedFrames >= 1, + lib, + 'gc calibrate frame gap', + `expected large frame gap or missedFrames≥1`, + ); + process.stderr.write( + ` calibrate: totalMs=${result.totalMs.toFixed(1)} frameIntervalMax=${result.frameIntervalMax.toFixed(1)} missedFrames=${result.missedFrames} displayPeriod=${result.displayPeriodMs.toFixed(2)}\n`, + ); + }, + { onlyLibs: ['data-client'] }, +); + +test( + 'GC scenario API: prepare/run/dispose cardinality (1k entity unique)', + async (page, lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.prepareGCScenario === 'function', + )) + ) + return; + + const prepared = await page.evaluate(async () => { + return window.__BENCH__!.prepareGCScenario!({ + candidateKind: 'entity', + pattern: 'unique', + count: 1000, + control: 'gc', + }); + }); + assert( + prepared.queueEntries === 1000, + lib, + 'gc prepare queueEntries', + `expected 1000, got ${prepared.queueEntries}`, + ); + assert( + prepared.uniqueTargets === 1000, + lib, + 'gc prepare uniqueTargets', + `expected 1000, got ${prepared.uniqueTargets}`, + ); + + const measurement = await page.evaluate(async () => { + return window.__BENCH__!.runGCScenario!(); + }); + assert( + measurement.schemaVersion === 1, + lib, + 'gc schemaVersion', + `expected 1, got ${measurement.schemaVersion}`, + ); + assert( + measurement.actionCount === 1, + lib, + 'gc actionCount', + `expected 1, got ${measurement.actionCount}`, + ); + assert( + measurement.deletionCount === 1000, + lib, + 'gc deletionCount', + `expected 1000, got ${measurement.deletionCount}`, + ); + assert( + measurement.actionTargetCount === 1000, + lib, + 'gc actionTargetCount', + `expected 1000, got ${measurement.actionTargetCount}`, + ); + assert( + measurement.sliceDurationsMs.length === 1, + lib, + 'gc sliceDurationsMs', + `expected 1 slice, got ${measurement.sliceDurationsMs.length}`, + ); + assert( + measurement.displayPeriodMs > 0, + lib, + 'gc displayPeriodMs', + `expected positive displayPeriodMs, got ${measurement.displayPeriodMs}`, + ); + assert( + typeof measurement.maxInputDelayMs === 'number' && + measurement.maxInputDelayMs >= 0, + lib, + 'gc maxInputDelayMs', + `expected non-negative maxInputDelayMs, got ${measurement.maxInputDelayMs}`, + ); + assert( + typeof measurement.missedFrames === 'number' && + measurement.missedFrames >= 0, + lib, + 'gc missedFrames', + `expected non-negative missedFrames, got ${measurement.missedFrames}`, + ); + + // no-gc control: same prepare/run path, zero actions/deletions + await page.evaluate(() => window.__BENCH__!.disposeGCScenario!()); + await page.evaluate(async () => { + await window.__BENCH__!.prepareGCScenario!({ + candidateKind: 'entity', + pattern: 'unique', + count: 1000, + control: 'no-gc', + }); + }); + const noGc = await page.evaluate(async () => { + return window.__BENCH__!.runGCScenario!(); + }); + assert( + noGc.actionCount === 0 && noGc.deletionCount === 0, + lib, + 'gc no-gc control', + `expected zero action/deletion, got action=${noGc.actionCount} deletion=${noGc.deletionCount}`, + ); + + // duplicate pattern: queueEntries=count, uniqueTargets=1, deletionCount=1 + await page.evaluate(() => window.__BENCH__!.disposeGCScenario!()); + const dupPrepared = await page.evaluate(async () => { + return window.__BENCH__!.prepareGCScenario!({ + candidateKind: 'entity', + pattern: 'duplicate', + count: 1000, + control: 'gc', + }); + }); + assert( + dupPrepared.queueEntries === 1000 && dupPrepared.uniqueTargets === 1, + lib, + 'gc duplicate prepare', + `expected queue=1000 unique=1, got queue=${dupPrepared.queueEntries} unique=${dupPrepared.uniqueTargets}`, + ); + const dup = await page.evaluate(async () => { + return window.__BENCH__!.runGCScenario!(); + }); + assert( + dup.deletionCount === 1 && dup.actionTargetCount === 1000, + lib, + 'gc duplicate run', + `expected deletion=1 actionTarget=1000, got deletion=${dup.deletionCount} actionTarget=${dup.actionTargetCount}`, + ); + + await page.evaluate(() => window.__BENCH__!.disposeGCScenario!()); + }, + { onlyLibs: ['data-client'] }, +); + // ── TIMING VALIDATION ──────────────────────────────────────────────── // Verify that when data-bench-complete fires (measurement ends), the DOM // already reflects the update. A 100ms network delay makes timing bugs diff --git a/examples/benchmark-react/package.json b/examples/benchmark-react/package.json index fa482a5ee468..d31550de002e 100644 --- a/examples/benchmark-react/package.json +++ b/examples/benchmark-react/package.json @@ -4,8 +4,8 @@ "private": true, "description": "React rendering benchmark comparing @data-client/react against other data libraries", "scripts": { - "build": "BROWSERSLIST_ENV=2026 webpack --mode=production", - "build:no-compiler": "BROWSERSLIST_ENV=2026 REACT_COMPILER=false webpack --mode=production", + "build": "BROWSERSLIST_ENV=2026 webpack --mode=production && npx tsx bench/build-manifest.ts", + "build:no-compiler": "BROWSERSLIST_ENV=2026 REACT_COMPILER=false webpack --mode=production && npx tsx bench/build-manifest.ts", "preview": "serve dist -l ${BENCH_PORT:-5173} --no-request-logging", "bench": "npx tsx bench/runner.ts", "bench:no-compiler": "BENCH_LABEL=no-compiler npx tsx bench/runner.ts", @@ -14,6 +14,9 @@ "bench:dc": "npx tsx bench/runner.ts --lib data-client", "bench:trace": "BENCH_V8_TRACE=true npx tsx bench/runner.ts --lib data-client --size small", "bench:deopt": "BENCH_V8_DEOPT=true npx tsx bench/runner.ts --lib data-client --size small", + "bench:gc": "npx tsx bench/gc-runner.ts", + "test:gc-metrics": "npx tsx bench/gc-interaction-metrics.test.ts", + "test:gc-provenance": "npx tsx bench/gc-provenance.test.ts", "bench:run": "yarn build && (yarn preview &) && sleep 5 && yarn bench", "bench:run:no-compiler": "yarn build:no-compiler && (yarn preview &) && sleep 5 && yarn bench:no-compiler", "validate": "npx tsx bench/validate.ts", diff --git a/examples/benchmark-react/src/data-client/gcBrowserHarness.ts b/examples/benchmark-react/src/data-client/gcBrowserHarness.ts new file mode 100644 index 000000000000..c5322881b854 --- /dev/null +++ b/examples/benchmark-react/src/data-client/gcBrowserHarness.ts @@ -0,0 +1,198 @@ +/** + * Isolated browser GC harness — separate from DataProvider's benchGC singleton. + * + * Builds a benchmark-only Controller + createReducer + timerless GCPolicy with + * raw deterministic state, queues via createCountRef, mode=end-to-end, using + * the fixture shared with the Node/Android harnesses (examples/gc-shared). Measures + * real core cache GC on the main thread without 100k React components or network. + * + * Timing boundaries: + * prepare — fixtures, queue, quiet displayPeriodMs (untimed) + * run — interaction probes + explicit sweep/no-op (timed); resolves after probes settle + * dispose — drop store/policy (after Playwright heapAfter) + */ +import { + Controller, + GCPolicy, + actionTypes, + createReducer, + initialState, +} from '@data-client/core'; +import type { + GCBrowserMeasurement, + GCPreparedSummary, + GCScenarioConfig, +} from '@shared/types'; + +import { measureDisplayPeriodMs } from './gcInteractionMetrics'; +import { + CALIBRATION_BLOCK_MS_MAX, + CALIBRATION_BLOCK_MS_MIN, + runChromiumInteractionProbe, + syntheticBlockMs, +} from './gcInteractionProbe'; +import { createGCFixture } from '../../../gc-shared/gc-fixture.js'; +import type { Harness } from '../../../gc-shared/gc-fixture.js'; + +// Destructure (not `actionTypes.GC` inline): a direct member read lets webpack +// inline the constant across chunks, which changes data-client.js bytes. +const { GC: GC_ACTION } = actionTypes; + +/** Shared fixture (examples/gc-shared) bound to this app's core. */ +const { createHarness, countRemaining, validateSample, GC } = createGCFixture({ + GCPolicy, + Controller, + createReducer, + initialState, + GC: GC_ACTION, +}); + +interface Session { + config: GCScenarioConfig; + harness: Harness; + displayPeriodMs: number; +} + +let session: Session | null = null; + +export async function prepareGCScenario( + config: GCScenarioConfig, +): Promise { + if (session) { + session.harness.dispose(); + session = null; + } + + const harness = createHarness(config, 'end-to-end'); + const displayPeriodMs = await measureDisplayPeriodMs(); + + session = { config, harness, displayPeriodMs }; + + return { + queueEntries: harness.expected.queueEntries, + uniqueTargets: harness.expected.uniqueTargets, + }; +} + +/** + * Interaction measurement via Chromium-calibrated probe: + * rAF registers the next rAF, then setTimeout(0) runs collection post-paint + * while a future frame is pending. See gcInteractionProbe.ts. + * `totalMs` is sweep/no-op only (scheduling excluded). + */ +export async function runGCScenario(): Promise { + if (!session) { + throw new Error( + 'prepareGCScenario() must be called before runGCScenario()', + ); + } + const { config, harness, displayPeriodMs } = session; + const { policy, expected } = harness; + + harness.clearCapturedAction(); + + let actionCount = 0; + let deletionCount = 0; + + const probe = await runChromiumInteractionProbe({ + displayPeriodMs, + work: () => { + if (config.control === 'gc') { + policy.sweep(); + const action = harness.getCapturedAction(); + actionCount = action && action.type === GC ? 1 : 0; + } + }, + }); + + // Deletion accounting outside timed work + if (config.control === 'gc') { + const remaining = countRemaining(harness.getState(), config, expected); + deletionCount = remaining.entityDeleted + remaining.endpointDeleted; + } + + const actionTargetCount = + config.control === 'gc' ? + expected.expectedEntitiesInAction + expected.expectedEndpointsInAction + : 0; + + const measurement: GCBrowserMeasurement = { + schemaVersion: 1, + totalMs: probe.totalMs, + sliceDurationsMs: config.control === 'gc' ? [probe.totalMs] : [], + actionCount, + queueEntries: expected.queueEntries, + uniqueTargets: expected.uniqueTargets, + actionTargetCount, + deletionCount, + timerDelayMs: probe.timerDelayMs, + frameIntervalsMs: probe.frameIntervalsMs, + displayPeriodMs: probe.displayPeriodMs, + missedFrames: probe.missedFrames, + maxInputDelayMs: probe.maxInputDelayMs, + longTaskCount: probe.longTaskCount, + longTaskTotalMs: probe.longTaskTotalMs, + }; + + validateSample(config, harness, measurement, 'end-to-end', config.control); + + // Drop captured GC action arrays before Playwright heap snapshot; keep live store + harness.clearCapturedAction(); + + return measurement; +} + +/** + * Validation/calibration only: synthetic 40–50ms block through the same probe. + * Confirms blocking spans pending frame boundaries (missedFrames / large interval). + * Not used by GC timing scenarios. + */ +export async function calibrateGCFrameProbe(blockMs?: number): Promise<{ + blockMs: number; + totalMs: number; + timerDelayMs: number; + displayPeriodMs: number; + frameIntervalsMs: number[]; + frameIntervalMax: number; + missedFrames: number; + maxInputDelayMs: number; + spannedPendingFrame: boolean; +}> { + const displayPeriodMs = + session?.displayPeriodMs ?? (await measureDisplayPeriodMs()); + const ms = Math.min( + CALIBRATION_BLOCK_MS_MAX, + Math.max( + CALIBRATION_BLOCK_MS_MIN, + blockMs ?? (CALIBRATION_BLOCK_MS_MIN + CALIBRATION_BLOCK_MS_MAX) / 2, + ), + ); + + const probe = await runChromiumInteractionProbe({ + displayPeriodMs, + work: () => syntheticBlockMs(ms), + }); + + const frameIntervalMax = + probe.frameIntervalsMs.length > 0 ? Math.max(...probe.frameIntervalsMs) : 0; + const spannedPendingFrame = + probe.missedFrames >= 1 || frameIntervalMax >= displayPeriodMs * 1.5; + + return { + blockMs: ms, + totalMs: probe.totalMs, + timerDelayMs: probe.timerDelayMs, + displayPeriodMs: probe.displayPeriodMs, + frameIntervalsMs: probe.frameIntervalsMs, + frameIntervalMax, + missedFrames: probe.missedFrames, + maxInputDelayMs: probe.maxInputDelayMs, + spannedPendingFrame, + }; +} + +export function disposeGCScenario(): void { + if (!session) return; + session.harness.dispose(); + session = null; +} diff --git a/examples/benchmark-react/src/data-client/gcInteractionMetrics.ts b/examples/benchmark-react/src/data-client/gcInteractionMetrics.ts new file mode 100644 index 000000000000..bc3f9fe0bb85 --- /dev/null +++ b/examples/benchmark-react/src/data-client/gcInteractionMetrics.ts @@ -0,0 +1,85 @@ +/** + * Pure GC interaction metric helpers + stable scenario IDs. + * Deterministic — safe to unit-test without Playwright timing. + */ + +import type { GCScenarioConfig } from '@shared/types'; + +import { + CANONICAL_COUNTS, + parseScenarioId, + scenarioId, +} from '../../../gc-shared/protocol.js'; + +export { + computeMaxInputDelayMs, + frameIntervalsFromTimestamps, + measureDisplayPeriodMs, + median, +} from '../../../gc-shared/frames.js'; + +/** + * Count excess whole display periods beyond the single expected vsync gap. + * + * Uses nearest-period (Math.round) rather than floor: rAF timestamps jitter, so + * a true 2-period stall often measures slightly under 2×displayPeriodMs + * (e.g. 1.98×). floor() would report 0 missed frames; round() recovers the + * intended whole-period count. Sub-period noise around 1× still rounds to 1 + * (zero excess). + */ +export function excessMissedFrames( + frameIntervalsMs: number[], + displayPeriodMs: number, +): number { + if (!(displayPeriodMs > 0)) return 0; + let missed = 0; + for (const interval of frameIntervalsMs) { + const periods = Math.round(interval / displayPeriodMs); + missed += Math.max(0, periods - 1); + } + return missed; +} + +/** LongTask overlaps measurement window (entry may start just before windowStart). */ +export function longTaskOverlapsWindow( + entry: { startTime: number; duration: number }, + windowStart: number, + windowEnd: number, +): boolean { + return ( + entry.startTime < windowEnd && + entry.startTime + entry.duration > windowStart + ); +} + +/** Canonical GC counts shared with the Node/Android harnesses (gc-shared protocol). */ +export const GC_CANONICAL_COUNTS = CANONICAL_COUNTS; + +/** + * Stable detailed-report scenario ID from validated axes (not display names). + * Format: browser/{kind}/{pattern}/{count}/end-to-end/{control} + */ +export function browserGCScenarioId( + axes: Pick< + GCScenarioConfig, + 'candidateKind' | 'pattern' | 'count' | 'control' + >, +): string { + return scenarioId({ platform: 'browser', mode: 'end-to-end', ...axes }); +} + +/** Parse a stable ID back to axes (throws on malformed or non-browser ids). */ +export function parseBrowserGCScenarioId(id: string): { + platform: 'browser'; + candidateKind: GCScenarioConfig['candidateKind']; + pattern: GCScenarioConfig['pattern']; + count: number; + mode: 'end-to-end'; + control: GCScenarioConfig['control']; +} { + const axes = parseScenarioId(id); + if (axes.platform !== 'browser') { + throw new Error(`malformed browser GC scenario id: ${id}`); + } + return { ...axes, platform: 'browser', mode: 'end-to-end' }; +} diff --git a/examples/benchmark-react/src/data-client/gcInteractionProbe.ts b/examples/benchmark-react/src/data-client/gcInteractionProbe.ts new file mode 100644 index 000000000000..c572a7ec4268 --- /dev/null +++ b/examples/benchmark-react/src/data-client/gcInteractionProbe.ts @@ -0,0 +1,235 @@ +/** + * Chromium-calibrated interaction probe for browser GC measurement. + * + * Ordering (Chromium-specific calibration — NOT a web platform guarantee): + * 1. In an rAF callback, register the *next* rAF first (future frame pending). + * 2. Then post setTimeout(0) so the collection/work task runs after paint. + * 3. Synchronous work in that timer therefore blocks while a frame is pending, + * so wall-clock frame intervals can observe the stalled vsync gap. + * + * `totalMs` times only `work()` — scheduling overhead is excluded. + * If `work()` throws, the probe rejects promptly, stops the rAF/timer chain, + * and disconnects the PerformanceObserver (no further mutations). + */ +import { + computeMaxInputDelayMs, + excessMissedFrames, + frameIntervalsFromTimestamps, + longTaskOverlapsWindow, +} from './gcInteractionMetrics'; + +export interface InteractionProbeResult { + totalMs: number; + timerDelayMs: number; + frameIntervalsMs: number[]; + displayPeriodMs: number; + missedFrames: number; + maxInputDelayMs: number; + longTaskCount: number; + longTaskTotalMs: number; +} + +const POST_FRAMES = 6; + +function drainAndDisconnectObserver( + observer: PerformanceObserver | null, + longTasks: { startTime: number; duration: number }[], +): void { + if (!observer) return; + try { + for (const entry of observer.takeRecords()) { + longTasks.push({ + startTime: entry.startTime, + duration: entry.duration, + }); + } + } catch { + // takeRecords unavailable + } + try { + observer.disconnect(); + } catch { + // ignore + } +} + +/** + * Run one Chromium-calibrated probe. `work` is the only timed body. + * Rejects if `work()` throws; settles exactly once. + */ +export async function runChromiumInteractionProbe(opts: { + displayPeriodMs: number; + work: () => void; +}): Promise { + const { displayPeriodMs, work } = opts; + + type LongTaskEntry = { startTime: number; duration: number }; + const longTasks: LongTaskEntry[] = []; + let observer: PerformanceObserver | null = null; + if (typeof PerformanceObserver !== 'undefined') { + try { + observer = new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + longTasks.push({ + startTime: entry.startTime, + duration: entry.duration, + }); + } + }); + observer.observe({ + type: 'longtask', + buffered: false, + } as PerformanceObserverInit); + } catch { + observer = null; + } + } + + const frameTimestamps: number[] = []; + let timerDelayMs = 0; + let totalMs = 0; + let windowStart = 0; + let windowEnd = 0; + + try { + await new Promise((resolve, reject) => { + let armed = false; + let collectionDone = false; + let postRemaining = POST_FRAMES; + /** Once true, no callback may mutate probe state or schedule further work. */ + let closed = false; + + let rafHandle: number | null = null; + let collectionTimer: ReturnType | null = null; + let delayProbeTimer: ReturnType | null = null; + let settleTimer: ReturnType | null = null; + + const clearPendingTimers = () => { + if (rafHandle != null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(rafHandle); + rafHandle = null; + } + if (collectionTimer != null) { + clearTimeout(collectionTimer); + collectionTimer = null; + } + if (delayProbeTimer != null) { + clearTimeout(delayProbeTimer); + delayProbeTimer = null; + } + if (settleTimer != null) { + clearTimeout(settleTimer); + settleTimer = null; + } + }; + + const settleOnce = (err?: unknown) => { + if (closed) return; + closed = true; + clearPendingTimers(); + if (err !== undefined) + reject(err instanceof Error ? err : new Error(String(err))); + else resolve(); + }; + + const scheduleFrame = () => { + if (closed) return; + rafHandle = requestAnimationFrame(onFrame); + }; + + const onFrame = (rafNow: number) => { + rafHandle = null; + if (closed) return; + + frameTimestamps.push(rafNow); + + if (!armed) { + armed = true; + // 1) Future frame pending BEFORE collection is posted. + scheduleFrame(); + // 2) Collection as macrotask — Chromium typically runs this post-paint. + collectionTimer = setTimeout(() => { + collectionTimer = null; + if (closed) return; + + windowStart = performance.now(); + const beforeProbe = performance.now(); + // Responsiveness probe: delay from immediately before timed work. + delayProbeTimer = setTimeout(() => { + delayProbeTimer = null; + if (closed) return; + timerDelayMs = performance.now() - beforeProbe; + }, 0); + + try { + const t0 = performance.now(); + work(); + totalMs = performance.now() - t0; + collectionDone = true; + } catch (err) { + settleOnce(err); + } + }, 0); + return; + } + + if (!collectionDone) { + // Keep the chain alive until collection finishes (blocked frames). + scheduleFrame(); + return; + } + + postRemaining--; + if (postRemaining > 0) { + scheduleFrame(); + return; + } + + windowEnd = performance.now(); + // Settle the responsiveness setTimeout(0) probe. + settleTimer = setTimeout(() => { + settleTimer = null; + if (closed) return; + settleOnce(); + }, 0); + }; + + scheduleFrame(); + }); + } finally { + // Resolve and reject both land here — drain + disconnect observer. + drainAndDisconnectObserver(observer, longTasks); + observer = null; + } + + const frameIntervalsMs = frameIntervalsFromTimestamps(frameTimestamps); + const longTasksInWindow = longTasks.filter(t => + longTaskOverlapsWindow(t, windowStart, windowEnd), + ); + + return { + totalMs, + timerDelayMs, + frameIntervalsMs, + displayPeriodMs, + missedFrames: excessMissedFrames(frameIntervalsMs, displayPeriodMs), + maxInputDelayMs: computeMaxInputDelayMs( + timerDelayMs, + frameIntervalsMs, + displayPeriodMs, + ), + longTaskCount: longTasksInWindow.length, + longTaskTotalMs: longTasksInWindow.reduce((s, t) => s + t.duration, 0), + }; +} + +/** Busy-wait for calibration only (not used in GC timing scenarios). */ +export function syntheticBlockMs(ms: number): void { + const end = performance.now() + ms; + while (performance.now() < end) { + // spin + } +} + +export const CALIBRATION_BLOCK_MS_MIN = 40; +export const CALIBRATION_BLOCK_MS_MAX = 50; diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index cf1af16ad3af..8688185cce62 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -30,11 +30,24 @@ import { sortedIssuesEndpoint, } from '@shared/resources'; import { patchIssue } from '@shared/server'; -import type { Issue } from '@shared/types'; +import type { GCScenarioConfig, Issue } from '@shared/types'; import React, { useCallback, useRef } from 'react'; let mutationCounter = 0; +/** Lazily loaded GC harness — never statically imported (bundle fairness). */ +type GCHarnessModule = typeof import('./gcBrowserHarness'); +let gcHarnessModule: GCHarnessModule | null = null; + +async function loadGCHarness(): Promise { + if (!gcHarnessModule) { + gcHarnessModule = await import( + /* webpackChunkName: "gc-browser-harness" */ './gcBrowserHarness' + ); + } + return gcHarnessModule; +} + /** GCPolicy with no interval (won't fire during timing scenarios) and instant * expiry so an explicit sweep() collects all unreferenced data immediately. */ class BenchGCPolicy extends GCPolicy { @@ -300,6 +313,25 @@ function BenchmarkHarness() { moveItem, triggerGC: () => benchGC.sweep(), resetStore, + prepareGCScenario: async (config: GCScenarioConfig) => { + const mod = await loadGCHarness(); + return mod.prepareGCScenario(config); + }, + runGCScenario: () => { + if (!gcHarnessModule) { + throw new Error( + 'prepareGCScenario() must load the GC harness before runGCScenario()', + ); + } + return gcHarnessModule.runGCScenario(); + }, + disposeGCScenario: () => { + gcHarnessModule?.disposeGCScenario(); + }, + calibrateGCFrameProbe: async (blockMs?: number) => { + const mod = await loadGCHarness(); + return mod.calibrateGCFrameProbe(blockMs); + }, }); return ( diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index bdde003695d8..00f89d3eed00 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -9,6 +9,56 @@ export interface RefStabilityReport { userRefChanged: number; } +/** Axes for browser GC scenarios (local mirror of the shared GC vocabulary). */ +export interface GCScenarioConfig { + candidateKind: 'entity' | 'endpoint' | 'mixed'; + /** `duplicate` is entity-only (one path released `count` times). */ + pattern: 'unique' | 'duplicate'; + /** Canonical counts: 1000 | 10000 | 100000 */ + count: number; + control: 'gc' | 'no-gc'; +} + +/** Queue cardinality after prepare (fixtures + createCountRef outside timing). */ +export interface GCPreparedSummary { + queueEntries: number; + uniqueTargets: number; +} + +/** + * Page-side GC interaction measurement (schemaVersion 1). + * Heap fields are attached by the Playwright runner, not page code. + */ +export interface GCBrowserMeasurement { + schemaVersion: 1; + totalMs: number; + /** Monolithic baseline: exactly `[totalMs]` when control is `gc`; empty for `no-gc`. */ + sliceDurationsMs: number[]; + actionCount: number; + queueEntries: number; + uniqueTargets: number; + actionTargetCount: number; + deletionCount: number; + /** Actual setTimeout(0) callback delay from immediately before collection. */ + timerDelayMs: number; + /** Raw rAF intervals around collection. */ + frameIntervalsMs: number[]; + /** Quiet-frame period measured during prepare (never hardcode 16.67). */ + displayPeriodMs: number; + /** + * Excess whole frame periods vs displayPeriodMs (nearest-period; see harness). + */ + missedFrames: number; + /** + * Responsiveness proxy: max(timerDelayMs, max excess of any frame interval + * over one displayPeriodMs). Not pointer/input-event latency — no synthetic + * pointer events are generated. + */ + maxInputDelayMs: number; + longTaskCount: number; + longTaskTotalMs: number; +} + /** * Benchmark API interface exposed by each library app on window.__BENCH__ */ @@ -57,6 +107,34 @@ export interface BenchAPI { setRenderLimit?(n: number | undefined): void; /** Clear client-side cache/store so the next mount triggers a fresh fetch. Called between sub-iterations for mount scenarios. */ resetStore?(): void; + /** + * Prepare isolated browser GC fixtures + queues (untimed). data-client only. + * Also measures quiet displayPeriodMs. Does not start interaction timing. + * Dynamically loads the GC harness chunk during prepare (before timing). + */ + prepareGCScenario?(config: GCScenarioConfig): Promise; + /** + * Run explicit monolithic cache GC (or no-gc control) with interaction probes. + * Resolves only after timer + rAF probes settle. data-client only. + */ + runGCScenario?(): Promise; + /** Tear down the isolated GC harness (store + policy). data-client only. */ + disposeGCScenario?(): void; + /** + * Validation/calibration only: synthetic ~40–50ms block through the Chromium + * frame probe. Confirms blocking spans pending frame boundaries. + */ + calibrateGCFrameProbe?(blockMs?: number): Promise<{ + blockMs: number; + totalMs: number; + timerDelayMs: number; + displayPeriodMs: number; + frameIntervalsMs: number[]; + frameIntervalMax: number; + missedFrames: number; + maxInputDelayMs: number; + spannedPendingFrame: boolean; + }>; } declare global { @@ -126,12 +204,9 @@ export type ScenarioAction = | { action: 'moveItem'; args: [number] }; export type ResultMetric = - | 'duration' - | 'issueRefChanged' - | 'userRefChanged' - | 'heapDelta'; + 'duration' | 'issueRefChanged' | 'userRefChanged' | 'heapDelta'; -/** hotPath = JS only, included in CI. memory = heap delta, not CI. startup = page load metrics, not CI. */ +/** hotPath = JS only, included in CI. memory = heap delta, not CI. startup = page load metrics, not CI. (Cache GC runs separately via bench/gc-runner.ts.) */ export type ScenarioCategory = 'hotPath' | 'memory' | 'startup'; /** small = cheap scenarios (full warmup + measurement). large = expensive scenarios (reduced runs). */ diff --git a/examples/benchmark-react/tsconfig.json b/examples/benchmark-react/tsconfig.json index c7f0e2350887..526ff1df4344 100644 --- a/examples/benchmark-react/tsconfig.json +++ b/examples/benchmark-react/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "strict": true, "esModuleInterop": true, @@ -11,6 +11,7 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, + "allowImportingTsExtensions": true, "paths": { "@shared/*": ["./src/shared/*"] }, diff --git a/examples/benchmark/README.md b/examples/benchmark/README.md index 760c3ed276c2..37403ac76f1a 100644 --- a/examples/benchmark/README.md +++ b/examples/benchmark/README.md @@ -21,13 +21,14 @@ yarn workspace example-benchmark start [suite-name] [filter] ``` Both arguments are optional: + - **No arguments**: runs `normalizr` + `core` suites with all benchmarks - **Suite only**: `yarn start normalizr` runs all benchmarks in that suite - **Suite + filter**: `yarn start normalizr denormalize` runs only benchmarks containing "denormalize" #### Filter syntax -- `text` → substring match (contains "text") +- `text` → substring match (contains) - `^text` → starts with "text" #### Suites @@ -78,6 +79,112 @@ counts (minor/major/other) and total GC pause during the loop, and retained heap after a full GC (should be ~0; nonzero indicates actual retention). Local-only — not tracked in CI. +### GC policy (isolated Node baseline) + +Measures the current monolithic cache GC (`GCPolicy.runSweep` + GC reducer) +under a timerless `BenchmarkGCPolicy` subclass: `expiresAt` always zero, `init` +only stores the controller (no intervals), `cleanup` is a no-op, and a public +`sweep()` calls protected `runSweep` once. This is **data-client cache GC**, not +V8/engine GC — do not conflate the two. + +```bash +yarn build:benchmark +yarn workspace example-benchmark start:gc [filter] [--samples=N] [--memory] [--table|--no-table] +yarn workspace example-benchmark start:gc:verify +``` + +Every `yarn build:benchmark` / workspace `build` runs webpack then writes +`dist/gc-build-manifest.json` (BuildManifest v1: `schemaVersion`, `buildId`, +`commit`, `dirty`, `sourceDigest`, `artifacts`). `sourceDigest` hashes sorted +relevant inputs on disk (`packages/{core,normalizr,endpoint}/src/**`, +`examples/gc-shared/**`, repo `babel.config.js` — webpack loads it with +`rootMode: 'upward'` — and GC harness/runner/config/package files), so dirty or +untracked relevant files change it. `artifacts` +maps each emitted `dist/*` file (except the manifest) to sha256. `buildId` is +the digest of those canonical fields excluding itself. + +`start:gc` **verifies** the manifest before scenarios: recomputes the current +source digest and every dist artifact hash, and rejects stale/missing/tampered +builds with a rebuild instruction. Report `build` provenance comes from the +**verified manifest**, never from live `git rev-parse` at run time. Manifest +generation and verification are outside timed cache-GC work. + +`start:gc:verify` self-tests that source and artifact +tampering are rejected and restores all files afterward. + +Examples: + +```bash +# All 1k scenarios (not 10k/100k); JSON on stdout +yarn workspace example-benchmark start:gc /1000/ --samples=5 + +# Single 100k entity scan without building unrelated fixtures +yarn workspace example-benchmark start:gc node/entity/unique/100000/scan/gc --samples=3 + +# Duplicate entity queue amplification baseline +yarn workspace example-benchmark start:gc entity/duplicate/1000 + +# Capture JSON only (no stderr table) +yarn workspace example-benchmark start:gc /1000/scan/gc --no-table > gc-report.json +``` + +Unlike the other suites' substring filter, `start:gc` filters match +**slash-bounded segments** of the stable scenario ID (`1000` and `/1000/` match +the 1000 count only, never 10000/100000; `entity/duplicate/1000` must be +contiguous segments), or `^text` for an ID prefix. The axes, IDs, and filter +are shared with the browser and Android harnesses via +[`examples/gc-shared`](../gc-shared/README.md): + +`node/{entity|endpoint|mixed}/{unique|duplicate}/{1000|10000|100000}/{scan|reducer|end-to-end}/{gc|no-gc}` + +| Axis | Values | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `candidateKind` | `entity`, `endpoint`, `mixed` (mixed splits total count across entity+endpoint) | +| `pattern` | `unique` (distinct keys/paths); `duplicate` (one entity path released `count` times; entity only) | +| `count` | Canonical `1000`, `10000`, `100000` | +| `mode` | `scan` (dispatch captures action, no reduce), `reducer` (prebuilt GC action through reducer), `end-to-end` (sweep dispatch reduces synchronously) | +| `control` | `gc` or `no-gc` (harness/no-op overhead) | + +**Setup / timing boundaries** + +1. **Prepare** (untimed) — build deterministic fixture state (`entities`/`entitiesMeta` or `endpoints`/`meta`), queue candidates via `createCountRef` mount/release. `reducer` mode prebuilds a GC action and clones state (seed dropped before timing/heap baseline). `scan` / `end-to-end` do **not** retain parallel 100k target arrays. +2. **Timed cache-GC** — `sweep()`, `reducer(state, action)`, or empty `no-op` body only. No per-candidate marks/logging inside this window. +3. **Optional retained-heap (`--memory`)** — after timed work + validation, drop observer refs (captured GC action, prebuilt action) while **keeping the live store**; then settle + forced V8 GC. Engine GC pauses are never folded into `totalMs`. + +Each destructive sample (`reducer` / `end-to-end`) gets a fresh fixture. Default +`--samples=11` (CLI-overridable). Fixtures for unmatched counts are never built, +so a filtered `100000` run does not construct 1k/10k stores. + +**Metrics** (JSON `schemaVersion: 1` on stdout) + +- `totalMs` — timed cache-GC wall time +- `sliceDurationsMs` — current monolith is **one slice** (honest representation; not fabricated per-candidate samples) +- `actionCount`, `queueEntries`, `uniqueTargets`, `actionTargetCount`, `deletionCount` +- For `duplicate`: queue/action may list the same entity path repeatedly; `uniqueTargets` / final `deletionCount` stay `1` +- Summary: median / min / max / p95 / p99 where meaningful; units are explicit in the report + +Every accepted sample is self-validated (queue cardinality, action target shape, +final deletions for reducer/end-to-end). Invalid fixtures/results throw. + +**`--memory` semantics (keep-store / drop-observer)** + +`heapDeltaBytes` is **not** “drop the whole harness and see what V8 frees.” That +would free the fixture for both `gc` and `no-gc` and hide cache deletion. +Instead: + +1. `heapBefore` — store + queues retained (reducer seed already dropped); forced V8 GC +2. timed cache-GC (or `no-gc` no-op) +3. validate, then release observer-only refs (captured action target arrays, prebuilt action) +4. `heapAfter` — **live store still retained**; settle + forced V8 GC + +Interpretation: compare `gc` vs `no-gc` on `reducer` / `end-to-end` — `gc` should +show a more negative (or smaller) retained heap after deleting cache entries. +`scan` does not mutate the store, so its heap delta is not a retained-cache signal. +Report includes `memorySemantics` documenting this model. Still separate from +engine GC timing. + +Local calibration harness only — not claimed as CI-integrated. + ### Profiling For opt/deopt investigation: diff --git a/examples/benchmark/gc-build-manifest.js b/examples/benchmark/gc-build-manifest.js new file mode 100644 index 000000000000..64cef86ae605 --- /dev/null +++ b/examples/benchmark/gc-build-manifest.js @@ -0,0 +1,509 @@ +/** + * BuildManifest v1 for the Node GC harness. + * + * Generated after webpack (`yarn build` / `yarn build:benchmark`) as + * `dist/gc-build-manifest.json`. `start:gc` verifies source + artifact digests + * before any scenario work and reports provenance from the verified manifest + * (never current HEAD alone). + * + * Usage: + * node ./gc-build-manifest.js write + * node ./gc-build-manifest.js verify + * node ./gc-build-manifest.js self-test + */ +import { Buffer } from 'node:buffer'; +import { execFileSync, execSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + existsSync, + readdirSync, + readFileSync, + writeFileSync, + unlinkSync, + statSync, +} from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BENCH_ROOT = __dirname; +const REPO_ROOT = path.resolve(BENCH_ROOT, '../..'); + +export const MANIFEST_SCHEMA_VERSION = 1; +export const MANIFEST_RELATIVE = 'dist/gc-build-manifest.json'; +export const MANIFEST_PATH = path.join(BENCH_ROOT, MANIFEST_RELATIVE); + +/** Benchmark harness / runner / config inputs (relative to repo root). */ +const BENCH_SOURCE_FILES = [ + 'examples/benchmark/gc-build-manifest.js', + 'examples/benchmark/gc-policy.js', + 'examples/benchmark/gc-policy-scenarios.js', + 'examples/benchmark/package.json', + 'examples/benchmark/webpack.config.cjs', + 'examples/benchmark/src/index.ts', + 'examples/benchmark/tsconfig.json', +]; + +/** + * Repo Babel config. Webpack's babel-loader uses rootMode: 'upward', so this + * file is what compiles the benchmark bundle. A change must change sourceDigest. + */ +const ROOT_CONFIG_FILES = ['babel.config.js']; + +const CORE_SRC_DIR = 'packages/core/src'; +/** + * Source directories walked recursively (relative to repo root): every + * package bundled into dist/index.js, plus the shared GC fixture/protocol. + */ +const SOURCE_DIRS = [ + CORE_SRC_DIR, + 'packages/normalizr/src', + 'packages/endpoint/src', + 'examples/gc-shared', +]; + +function sha256Buffer(buf) { + return createHash('sha256').update(buf).digest('hex'); +} + +function sha256Text(text) { + return sha256Buffer(Buffer.from(text, 'utf8')); +} + +function toPosix(p) { + return p.split(path.sep).join('/'); +} + +/** Walk a directory; return repo-relative posix paths (files only), sorted. */ +function walkFiles(absDir, repoRelDir) { + const out = []; + if (!existsSync(absDir)) return out; + const entries = readdirSync(absDir, { withFileTypes: true }); + for (const ent of entries) { + const abs = path.join(absDir, ent.name); + const rel = toPosix(path.join(repoRelDir, ent.name)); + if (ent.isDirectory()) { + out.push(...walkFiles(abs, rel)); + } else if (ent.isFile()) { + out.push(rel); + } + } + return out; +} + +/** + * Sorted list of relevant source paths (repo-relative posix). + * Includes untracked files under SOURCE_DIRS when present on disk. + */ +export function listRelevantSourcePaths() { + const dirFiles = SOURCE_DIRS.flatMap(dir => + walkFiles(path.join(REPO_ROOT, dir), dir), + ); + const benchFiles = BENCH_SOURCE_FILES.filter(rel => + existsSync(path.join(REPO_ROOT, rel)), + ); + return [...new Set([...dirFiles, ...benchFiles, ...ROOT_CONFIG_FILES])].sort( + (a, b) => a.localeCompare(b), + ); +} + +/** + * Canonical source digest over sorted path→content (filesystem, not git blobs). + * Dirty/untracked relevant files change this digest. + */ +export function computeSourceDigest(paths = listRelevantSourcePaths()) { + const hash = createHash('sha256'); + for (const rel of paths) { + const abs = path.join(REPO_ROOT, rel); + if (!existsSync(abs) || !statSync(abs).isFile()) { + throw new Error(`relevant source missing: ${rel}`); + } + hash.update(rel); + hash.update('\0'); + hash.update(readFileSync(abs)); + hash.update('\0'); + } + return hash.digest('hex'); +} + +/** Dist artifact paths relative to examples/benchmark, excluding the manifest. */ +export function listDistArtifactPaths() { + const distDir = path.join(BENCH_ROOT, 'dist'); + if (!existsSync(distDir)) return []; + const out = []; + for (const name of readdirSync(distDir)) { + if (name === 'gc-build-manifest.json') continue; + const abs = path.join(distDir, name); + if (statSync(abs).isFile()) { + out.push(toPosix(path.join('dist', name))); + } + } + return out.sort((a, b) => a.localeCompare(b)); +} + +export function hashDistArtifacts(paths = listDistArtifactPaths()) { + /** @type {Record} */ + const artifacts = {}; + for (const rel of paths) { + const abs = path.join(BENCH_ROOT, rel); + if (!existsSync(abs)) { + throw new Error(`dist artifact missing: ${rel}`); + } + artifacts[rel] = sha256Buffer(readFileSync(abs)); + } + return artifacts; +} + +export function gitCommitFull() { + try { + return execSync('git rev-parse HEAD', { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return 'unknown'; + } +} + +/** + * dirty=true when any relevant path is modified, added, or untracked vs git. + */ +export function computeDirty() { + try { + // Pathspecs already scope status to relevant inputs. + const pathspecs = [ + ...SOURCE_DIRS, + ...BENCH_SOURCE_FILES, + ...ROOT_CONFIG_FILES, + ]; + const out = execFileSync( + 'git', + ['status', '--porcelain', '-u', '--', ...pathspecs], + { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }, + ).trim(); + return out.length > 0; + } catch { + return true; + } +} + +/** + * buildId = digest of canonical manifest fields excluding buildId itself. + */ +export function computeBuildId({ + schemaVersion, + commit, + dirty, + sourceDigest, + artifacts, +}) { + const sortedArtifacts = Object.fromEntries( + Object.entries(artifacts).sort(([a], [b]) => a.localeCompare(b)), + ); + const canonical = JSON.stringify({ + schemaVersion, + commit, + dirty, + sourceDigest, + artifacts: sortedArtifacts, + }); + return sha256Text(canonical); +} + +export function buildManifest() { + const paths = listRelevantSourcePaths(); + const sourceDigest = computeSourceDigest(paths); + const artifacts = hashDistArtifacts(); + if (Object.keys(artifacts).length === 0) { + throw new Error( + 'no dist artifacts found; run webpack before writing the GC build manifest', + ); + } + const commit = gitCommitFull(); + const dirty = computeDirty(); + const body = { + schemaVersion: MANIFEST_SCHEMA_VERSION, + commit, + dirty, + sourceDigest, + artifacts, + }; + const buildId = computeBuildId(body); + return { + schemaVersion: MANIFEST_SCHEMA_VERSION, + buildId, + commit, + dirty, + sourceDigest, + artifacts, + }; +} + +export function writeManifest(manifest = buildManifest()) { + writeFileSync( + MANIFEST_PATH, + `${JSON.stringify(manifest, null, 2)}\n`, + 'utf8', + ); + return manifest; +} + +export function readManifest(manifestPath = MANIFEST_PATH) { + if (!existsSync(manifestPath)) { + return null; + } + return JSON.parse(readFileSync(manifestPath, 'utf8')); +} + +const REBUILD_HINT = + 'Rebuild with: yarn build:benchmark (or: yarn workspace example-benchmark run build)'; + +/** + * Recompute current source digest + dist artifact hashes and compare to manifest. + * @returns {{ ok: true, manifest: object } | { ok: false, reason: string, details?: string[] }} + */ +export function verifyManifest(manifestPath = MANIFEST_PATH) { + const details = []; + if (!existsSync(manifestPath)) { + return { + ok: false, + reason: `missing build manifest at ${MANIFEST_RELATIVE}`, + details: [REBUILD_HINT], + }; + } + + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + } catch (err) { + return { + ok: false, + reason: `unreadable build manifest: ${err.message}`, + details: [REBUILD_HINT], + }; + } + + if (manifest.schemaVersion !== MANIFEST_SCHEMA_VERSION) { + return { + ok: false, + reason: `unsupported manifest schemaVersion ${manifest.schemaVersion} (expected ${MANIFEST_SCHEMA_VERSION})`, + details: [REBUILD_HINT], + }; + } + + const expectedBuildId = computeBuildId({ + schemaVersion: manifest.schemaVersion, + commit: manifest.commit, + dirty: manifest.dirty, + sourceDigest: manifest.sourceDigest, + artifacts: manifest.artifacts ?? {}, + }); + if (manifest.buildId !== expectedBuildId) { + details.push( + `buildId mismatch (manifest tampered or corrupt): got ${manifest.buildId}, expected ${expectedBuildId}`, + ); + } + + let currentSourceDigest; + try { + currentSourceDigest = computeSourceDigest(); + } catch (err) { + return { + ok: false, + reason: err.message, + details: [REBUILD_HINT], + }; + } + if (currentSourceDigest !== manifest.sourceDigest) { + details.push( + `sourceDigest stale: manifest ${manifest.sourceDigest.slice(0, 12)}… vs current ${currentSourceDigest.slice(0, 12)}… (relevant sources changed since build)`, + ); + } + + const currentArtifacts = hashDistArtifacts(); + const manifestArts = manifest.artifacts ?? {}; + const allKeys = new Set([ + ...Object.keys(manifestArts), + ...Object.keys(currentArtifacts), + ]); + for (const key of [...allKeys].sort()) { + if (!(key in manifestArts)) { + details.push(`unexpected dist artifact not in manifest: ${key}`); + } else if (!(key in currentArtifacts)) { + details.push(`missing dist artifact listed in manifest: ${key}`); + } else if (manifestArts[key] !== currentArtifacts[key]) { + details.push( + `dist artifact hash mismatch: ${key} (tampered or stale webpack output)`, + ); + } + } + + if (details.length) { + return { + ok: false, + reason: 'GC build provenance verification failed', + details: [...details, REBUILD_HINT], + }; + } + + return { ok: true, manifest }; +} + +export function verifyManifestOrThrow(manifestPath = MANIFEST_PATH) { + const result = verifyManifest(manifestPath); + if (!result.ok) { + const msg = [result.reason, ...(result.details ?? [])].join('\n '); + throw new Error(msg); + } + return result.manifest; +} + +/** + * Prove source + artifact tampering rejects, restoring all files afterward. + */ +export function runSelfTest() { + const steps = []; + const baseline = verifyManifest(); + if (!baseline.ok) { + throw new Error( + `self-test requires a valid build first:\n ${baseline.reason}\n ${REBUILD_HINT}`, + ); + } + steps.push('baseline verify: ok'); + + // --- artifact tamper --- + const artifactRel = Object.keys(baseline.manifest.artifacts)[0]; + if (!artifactRel) throw new Error('self-test: no artifacts in manifest'); + const artifactAbs = path.join(BENCH_ROOT, artifactRel); + const artifactOrig = readFileSync(artifactAbs); + try { + writeFileSync( + artifactAbs, + Buffer.concat([artifactOrig, Buffer.from('\n')]), + ); + const afterArt = verifyManifest(); + if (afterArt.ok) { + throw new Error('self-test: expected artifact tampering to be rejected'); + } + steps.push( + `artifact tamper rejected: ${afterArt.details?.[0] ?? afterArt.reason}`, + ); + } finally { + writeFileSync(artifactAbs, artifactOrig); + } + const restoredArt = verifyManifest(); + if (!restoredArt.ok) { + throw new Error( + `self-test: artifact restore failed verify: ${restoredArt.reason}`, + ); + } + steps.push('artifact restore: ok'); + + // --- source tamper via temporary untracked file under packages/core/src --- + const tmpRel = `${CORE_SRC_DIR}/.__gc_manifest_selftest__.tmp`; + const tmpAbs = path.join(REPO_ROOT, tmpRel); + try { + writeFileSync(tmpAbs, '// gc-build-manifest self-test — safe to delete\n'); + const afterSrc = verifyManifest(); + if (afterSrc.ok) { + throw new Error('self-test: expected source tampering to be rejected'); + } + steps.push( + `source tamper rejected: ${afterSrc.details?.[0] ?? afterSrc.reason}`, + ); + } finally { + if (existsSync(tmpAbs)) unlinkSync(tmpAbs); + } + const restoredSrc = verifyManifest(); + if (!restoredSrc.ok) { + throw new Error( + `self-test: source restore failed verify: ${restoredSrc.reason}`, + ); + } + steps.push('source restore: ok'); + + // --- manifest buildId tamper --- + const manifestOrig = readFileSync(MANIFEST_PATH); + try { + const mangled = JSON.parse(manifestOrig.toString('utf8')); + mangled.buildId = '0'.repeat(64); + writeFileSync(MANIFEST_PATH, `${JSON.stringify(mangled, null, 2)}\n`); + const afterMan = verifyManifest(); + if (afterMan.ok) { + throw new Error( + 'self-test: expected manifest buildId tamper to be rejected', + ); + } + steps.push( + `manifest tamper rejected: ${afterMan.details?.[0] ?? afterMan.reason}`, + ); + } finally { + writeFileSync(MANIFEST_PATH, manifestOrig); + } + const final = verifyManifest(); + if (!final.ok) { + throw new Error(`self-test: final verify failed: ${final.reason}`); + } + steps.push('final verify: ok'); + + return { ok: true, steps, manifest: final.manifest }; +} + +function printVerifyFailure(result) { + console.error(result.reason); + for (const d of result.details ?? []) { + console.error(` ${d}`); + } +} + +function main(argv) { + const cmd = argv[0] ?? 'write'; + if (cmd === 'write') { + const manifest = writeManifest(); + console.error( + `wrote ${MANIFEST_RELATIVE} buildId=${manifest.buildId.slice(0, 12)}… commit=${manifest.commit.slice(0, 12)}… dirty=${manifest.dirty} artifacts=${Object.keys(manifest.artifacts).length}`, + ); + return; + } + if (cmd === 'verify') { + const result = verifyManifest(); + if (!result.ok) { + printVerifyFailure(result); + process.exitCode = 1; + return; + } + console.error( + `GC build manifest OK buildId=${result.manifest.buildId.slice(0, 12)}… commit=${result.manifest.commit.slice(0, 12)}…`, + ); + return; + } + if (cmd === 'self-test') { + try { + const result = runSelfTest(); + for (const step of result.steps) { + console.error(` ${step}`); + } + console.error('gc-build-manifest self-test passed'); + } catch (err) { + console.error(err.message ?? err); + process.exitCode = 1; + } + return; + } + console.error(`unknown command: ${cmd} (expected write|verify|self-test)`); + process.exitCode = 1; +} + +const isMain = + process.argv[1] != null && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + main(process.argv.slice(2)); +} diff --git a/examples/benchmark/gc-build-manifest.test.js b/examples/benchmark/gc-build-manifest.test.js new file mode 100644 index 000000000000..72dd67b18aaa --- /dev/null +++ b/examples/benchmark/gc-build-manifest.test.js @@ -0,0 +1,39 @@ +/** + * Node GC sourceDigest includes repo babel.config.js. + * Webpack's babel-loader uses rootMode: 'upward', so that file compiles the bundle. + * + * node --test examples/benchmark/gc-build-manifest.test.js + */ +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + computeSourceDigest, + listRelevantSourcePaths, +} from './gc-build-manifest.js'; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +); +const babelPath = path.join(repoRoot, 'babel.config.js'); + +test('repo babel.config.js is a sourceDigest input', () => { + assert.ok(listRelevantSourcePaths().includes('babel.config.js')); +}); + +test('a babel.config.js change changes sourceDigest and restores', () => { + const original = readFileSync(babelPath); + const before = computeSourceDigest(); + try { + writeFileSync(babelPath, `${original.toString('utf8')}\n`); + const after = computeSourceDigest(); + assert.notEqual(after, before); + } finally { + writeFileSync(babelPath, original); + } + assert.equal(computeSourceDigest(), before); +}); diff --git a/examples/benchmark/gc-policy-scenarios.js b/examples/benchmark/gc-policy-scenarios.js new file mode 100644 index 000000000000..daaeb5b38814 --- /dev/null +++ b/examples/benchmark/gc-policy-scenarios.js @@ -0,0 +1,46 @@ +/** + * Node GC harness binding of the shared fixture/protocol (examples/gc-shared) + * to the webpack-built core in ./dist (not `@data-client/core` source). + */ +import { + GCPolicy, + Controller, + createReducer, + initialState, + actionTypes, +} from './dist/index.js'; +import { createGCFixture } from '../gc-shared/gc-fixture.js'; +import { listScenarios as listSharedScenarios } from '../gc-shared/protocol.js'; + +export const { + ENTITY_KEY, + ZERO_META, + BenchmarkGCPolicy, + buildEntityState, + buildEndpointState, + buildMixedState, + queueCandidates, + buildPrebuiltAction, + createHarness, + countRemaining, + validateSample, + GC, +} = createGCFixture({ + GCPolicy, + Controller, + createReducer, + initialState, + GC: actionTypes.GC, +}); + +/** + * Node scenario descriptors (count → kind → mode → control, then the entity + * duplicate baseline per count). Fixtures are not built here, so filtering a + * single 100k case never constructs unrelated stores. + * + * @param {string} [filter] slash-bounded segments or ^prefix against the id + * @returns {Array} `{ id, platform, candidateKind, pattern, count, mode, control }` + */ +export function listScenarios(filter) { + return listSharedScenarios('node', { filter }); +} diff --git a/examples/benchmark/gc-policy.js b/examples/benchmark/gc-policy.js new file mode 100644 index 000000000000..5ef3493304d8 --- /dev/null +++ b/examples/benchmark/gc-policy.js @@ -0,0 +1,351 @@ +/** + * Isolated Node GC measurement harness (monolithic baseline). + * + * Timing windows (see plans/garbage-collection.md): + * 1. Prepare — fixture + queue outside the timed window + * 2. Timed cache-GC work — sweep / reducer / no-op control + * 3. Optional retained-heap path — validate, drop observer refs (captured + * action / prebuilt action / seed clone), keep live store, then settle + + * forced V8 GC. Engine GC is never inside the timed cache-GC section. + * + * JSON report (schemaVersion 1) → stdout + * Optional human table → stderr (--table / default when stderr is a TTY) + * + * Usage: + * yarn build:benchmark + * yarn workspace example-benchmark start:gc [filter] [--samples=N] [--memory] [--table|--no-table] + * yarn workspace example-benchmark start:gc:verify + */ +import os from 'node:os'; +import process from 'node:process'; +import { setImmediate } from 'node:timers'; +import v8 from 'node:v8'; +import vm from 'node:vm'; + +import { verifyManifestOrThrow } from './gc-build-manifest.js'; +import { + listScenarios, + createHarness, + validateSample, + countRemaining, + GC, +} from './gc-policy-scenarios.js'; +import { summarizeNumbers } from '../gc-shared/stats.js'; + +const DEFAULT_SAMPLES = 11; + +function parseArgs(argv) { + let filter; + let samples = DEFAULT_SAMPLES; + let memory = false; + let table; + for (const arg of argv) { + if (arg === '--memory') { + memory = true; + } else if (arg === '--table') { + table = true; + } else if (arg === '--no-table') { + table = false; + } else if (arg.startsWith('--samples=')) { + samples = Number(arg.slice('--samples='.length)); + if (!Number.isFinite(samples) || samples < 1) { + throw new Error(`invalid --samples value: ${arg}`); + } + samples = Math.floor(samples); + } else if (arg.startsWith('-')) { + throw new Error(`unknown flag: ${arg}`); + } else if (filter === undefined) { + filter = arg; + } else { + throw new Error(`unexpected argument: ${arg}`); + } + } + if (table === undefined) table = Boolean(process.stderr.isTTY); + return { filter, samples, memory, table }; +} + +function cpuModel() { + return os.cpus()[0]?.model ?? 'unknown'; +} + +function summarizeScenario(samples) { + const summary = { + totalMs: summarizeNumbers(samples.map(s => s.totalMs)), + actionCount: summarizeNumbers(samples.map(s => s.actionCount)), + queueEntries: summarizeNumbers(samples.map(s => s.queueEntries)), + uniqueTargets: summarizeNumbers(samples.map(s => s.uniqueTargets)), + actionTargetCount: summarizeNumbers(samples.map(s => s.actionTargetCount)), + deletionCount: summarizeNumbers(samples.map(s => s.deletionCount)), + }; + const slices = samples.flatMap(s => s.sliceDurationsMs ?? []); + if (slices.length) { + summary.sliceDurationsMs = summarizeNumbers(slices); + } + const heaps = samples.filter(s => s.heapBeforeBytes != null); + if (heaps.length) { + summary.heapBeforeBytes = summarizeNumbers( + heaps.map(s => s.heapBeforeBytes), + ); + summary.heapAfterBytes = summarizeNumbers(heaps.map(s => s.heapAfterBytes)); + summary.heapDeltaBytes = summarizeNumbers(heaps.map(s => s.heapDeltaBytes)); + } + return summary; +} + +function maybeExposeGc() { + try { + v8.setFlagsFromString('--expose_gc'); + return vm.runInNewContext('gc'); + } catch { + return undefined; + } +} + +function fullGC(gc) { + if (!gc) return; + gc(); + gc(); +} + +function heapUsed() { + return v8.getHeapStatistics().used_heap_size; +} + +async function settleEventLoop() { + await new Promise(resolve => setImmediate(resolve)); + await new Promise(resolve => setImmediate(resolve)); +} + +/** + * Run one sample. Setup/queue are outside the timed section. + * Destructive modes rebuild a fresh harness each sample. + * + * Memory lifecycle (--memory), keep-store / drop-observer: + * prepare → (reducer: take clone, drop seed) → V8 GC → heapBefore + * → timed cache-GC → validate → releaseObserverRefs (action/seed only) + * → settle + V8 GC → heapAfter (live store still retained) + * → dispose harness for the next sample + * + * heapDelta therefore reflects cache contents still held by the store, not + * harness/action observer arrays. Compare gc vs no-gc end-to-end/reducer. + */ +async function runSample(scenario, { memory, gc }) { + const { mode, control } = scenario; + const harness = createHarness(scenario, mode); + const { policy, reducer, expected } = harness; + + const queueEntries = expected.queueEntries; + const uniqueTargets = expected.uniqueTargets; + const actionTargetCount = + expected.expectedEntitiesInAction + expected.expectedEndpointsInAction; + + // Reducer: clone into working state and drop the full-size seed before baseline + let reducerState; + if (mode === 'reducer') { + reducerState = harness.takeReducerState(); + } + + let heapBeforeBytes; + if (memory) { + fullGC(gc); + await settleEventLoop(); + heapBeforeBytes = heapUsed(); + } + + harness.clearCapturedAction(); + + const t0 = performance.now(); + let actionCount = 0; + let deletionCount = 0; + + if (control === 'no-gc') { + // Harness / no-op overhead only — intentionally empty + } else if (mode === 'scan') { + policy.sweep(); + actionCount = harness.getCapturedAction() ? 1 : 0; + } else if (mode === 'reducer') { + reducer(reducerState, harness.prebuiltAction); + actionCount = 1; + } else if (mode === 'end-to-end') { + policy.sweep(); + const action = harness.getCapturedAction(); + actionCount = action && action.type === GC ? 1 : 0; + } else { + throw new Error(`unknown mode: ${mode}`); + } + + const totalMs = performance.now() - t0; + + // Deletion accounting is outside the timed window + if (control === 'gc' && (mode === 'reducer' || mode === 'end-to-end')) { + const remaining = countRemaining(harness.getState(), scenario, expected); + deletionCount = remaining.entityDeleted + remaining.endpointDeleted; + } + + // Monolithic GC: report one slice only (honest representation of current behavior) + const sliceDurationsMs = control === 'gc' ? [totalMs] : []; + + const sample = { + totalMs, + sliceDurationsMs, + actionCount, + queueEntries, + uniqueTargets, + actionTargetCount: control === 'gc' ? actionTargetCount : 0, + deletionCount, + }; + + // Validate while harness/action refs still exist + validateSample(scenario, harness, sample, mode, control); + + if (memory) { + // Drop captured GC action (holds target arrays) and prebuilt action; keep store + harness.releaseObserverRefs(); + reducerState = undefined; + await settleEventLoop(); + fullGC(gc); + const heapAfterBytes = heapUsed(); + sample.heapBeforeBytes = heapBeforeBytes; + sample.heapAfterBytes = heapAfterBytes; + sample.heapDeltaBytes = heapAfterBytes - heapBeforeBytes; + } + + harness.dispose(); + + return sample; +} + +function formatMs(n) { + if (n == null) return '—'; + if (n < 1) return `${(n * 1000).toFixed(1)} µs`; + if (n < 1000) return `${n.toFixed(2)} ms`; + return `${(n / 1000).toFixed(2)} s`; +} + +function formatBytes(n) { + if (n == null) return '—'; + const abs = Math.abs(n); + const sign = n < 0 ? '-' : ''; + if (abs >= 1024 * 1024) + return `${sign}${(abs / (1024 * 1024)).toFixed(2)} MB`; + if (abs >= 1024) return `${sign}${(abs / 1024).toFixed(1)} KB`; + return `${n} B`; +} + +function printTable(scenarioResults) { + const rows = scenarioResults.map(({ id, summary }) => { + const row = { + scenario: id, + 'median total': formatMs(summary.totalMs?.median), + min: formatMs(summary.totalMs?.min), + max: formatMs(summary.totalMs?.max), + p95: formatMs(summary.totalMs?.p95), + actions: summary.actionCount?.median ?? '—', + queue: summary.queueEntries?.median ?? '—', + targets: summary.actionTargetCount?.median ?? '—', + deleted: summary.deletionCount?.median ?? '—', + }; + if (summary.heapDeltaBytes) { + row['heap Δ'] = formatBytes(summary.heapDeltaBytes.median); + } + return row; + }); + const err = new console.Console(process.stderr, process.stderr); + err.log('\nGC policy benchmark (cache GC — not V8 engine GC)\n'); + err.table(rows); +} + +async function main() { + const { + filter, + samples: sampleCount, + memory, + table, + } = parseArgs(process.argv.slice(2)); + + // Provenance check is outside timing and runs before any scenario work + const buildManifest = verifyManifestOrThrow(); + + const scenarios = listScenarios(filter); + if (scenarios.length === 0) { + throw new Error(`no scenarios matched filter: ${filter ?? '(none)'}`); + } + + const gc = memory ? maybeExposeGc() : undefined; + if (memory && !gc) { + console.error( + 'warning: --memory requested but V8 gc() is unavailable; heap metrics omitted', + ); + } + + const scenarioResults = []; + for (const scenario of scenarios) { + const samples = []; + for (let i = 0; i < sampleCount; i++) { + samples.push(await runSample(scenario, { memory: Boolean(gc), gc })); + } + scenarioResults.push({ + id: scenario.id, + platform: scenario.platform, + candidateKind: scenario.candidateKind, + pattern: scenario.pattern, + count: scenario.count, + mode: scenario.mode, + control: scenario.control, + samples, + summary: summarizeScenario(samples), + }); + } + + const report = { + schemaVersion: 1, + units: { + totalMs: 'milliseconds', + sliceDurationsMs: 'milliseconds', + heapBeforeBytes: 'bytes', + heapAfterBytes: 'bytes', + heapDeltaBytes: 'bytes', + }, + memorySemantics: + memory ? + { + model: 'keep-store-drop-observer', + description: + 'heapBefore is taken after fixture/queue prepare (and reducer seed drop) with a forced V8 GC. After timed cache-GC and validation, observer refs (captured GC action target arrays, prebuilt reducer action, reducer seed) are released; the live store remains. heapAfter follows settle + forced V8 GC. heapDeltaBytes ≈ retained store cache change, not harness retention. Compare gc vs no-gc for reducer/end-to-end; scan does not delete store entries so its delta is not a retained-cache signal.', + } + : undefined, + build: { + schemaVersion: buildManifest.schemaVersion, + buildId: buildManifest.buildId, + commit: buildManifest.commit, + dirty: buildManifest.dirty, + sourceDigest: buildManifest.sourceDigest, + artifacts: buildManifest.artifacts, + label: 'examples/benchmark GC monolithic baseline', + manifest: 'dist/gc-build-manifest.json', + }, + environment: { + runtime: 'node', + nodeVersion: process.version, + os: `${os.type()} ${os.release()}`, + arch: os.arch(), + cpuModel: cpuModel(), + cpus: os.cpus().length, + platform: process.platform, + }, + config: { + samplesPerScenario: sampleCount, + filter: filter ?? null, + memoryMetrics: Boolean(gc), + }, + scenarios: scenarioResults, + }; + + if (table) printTable(scenarioResults); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} + +main().catch(err => { + console.error(err); + process.exitCode = 1; +}); diff --git a/examples/benchmark/package.json b/examples/benchmark/package.json index 097714f6ec53..3affce30e044 100644 --- a/examples/benchmark/package.json +++ b/examples/benchmark/package.json @@ -11,9 +11,11 @@ "node": ">=22" }, "scripts": { - "build": "webpack --mode=production --target=node --env readable", + "build": "webpack --mode=production --target=node --env readable && node ./gc-build-manifest.js write", "start": "NODE_ENV=production node --allow-natives-syntax ./index.js", "start:memory": "NODE_ENV=production node ./memory.js", + "start:gc": "NODE_ENV=production node ./gc-policy.js", + "start:gc:verify": "node ./gc-build-manifest.js self-test", "start:trace": "yarn run start --trace_opt --trace_deopt", "start:deopt": "NODE_ENV=production npx dexnode --out v8.log --redirect-code-traces-to=/tmp/codetrace --allow-natives-syntax ./index.js" }, diff --git a/examples/gc-shared/README.md b/examples/gc-shared/README.md new file mode 100644 index 000000000000..35f512fa5140 --- /dev/null +++ b/examples/gc-shared/README.md @@ -0,0 +1,18 @@ +# GC harness shared code + +Fixture, protocol (axes, stable ids, filters), frame helpers, and report stats +shared by the GC harnesses in `examples/benchmark` (Node), +`examples/benchmark-react` (browser), and `examples/benchmark-native` (Android). +See [plans/garbage-collection.md](../../plans/garbage-collection.md). + +- **CommonJS `.js` + hand-written `.d.ts` on purpose.** It is the one format + Node ESM (named imports via the literal `module.exports = { ... }`), webpack + (outside babel's `src/` include), Metro (no `.mjs`/`.cjs`), Jest, and tsx all + load with zero config. Keep it plain ES2020: no TS, JSX, or Node globals in + bundled files. +- **Not a workspace** (no `package.json`), so the root `package.json` (no + `"type"`) makes `.js` CommonJS. +- `protocol-cli.js` is the host-only CLI used by benchmark-native shell scripts; + never import it from app code. +- Each harness's build provenance digest includes this folder; keep it that way + when adding files. diff --git a/examples/gc-shared/frames.d.ts b/examples/gc-shared/frames.d.ts new file mode 100644 index 000000000000..26770cedf6a1 --- /dev/null +++ b/examples/gc-shared/frames.d.ts @@ -0,0 +1,15 @@ +export declare function median(values: number[]): number; +/** Adjacent differences of rAF timestamps (ms). */ +export declare function frameIntervalsFromTimestamps( + timestamps: number[], +): number[]; +/** max(timerDelayMs, largest frame-interval excess over one display period). */ +export declare function computeMaxInputDelayMs( + timerDelayMs: number, + frameIntervalsMs: number[], + displayPeriodMs: number, +): number; +/** Median of quiet rAF intervals (uses global requestAnimationFrame). */ +export declare function measureDisplayPeriodMs( + samples?: number, +): Promise; diff --git a/examples/gc-shared/frames.js b/examples/gc-shared/frames.js new file mode 100644 index 000000000000..b56e29f98daa --- /dev/null +++ b/examples/gc-shared/frames.js @@ -0,0 +1,71 @@ +'use strict'; +/** + * JS frame / responsiveness helpers shared by the browser and Android GC + * harnesses (rAF + timers; not pointer latency). Missed-frame counting stays + * platform-local because capture sources differ. + * CommonJS on purpose (see ./README.md). + */ + +function median(values) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? + (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; +} + +/** Adjacent differences of rAF timestamps (ms). */ +function frameIntervalsFromTimestamps(timestamps) { + const intervals = []; + for (let i = 1; i < timestamps.length; i++) { + intervals.push(timestamps[i] - timestamps[i - 1]); + } + return intervals; +} + +/** + * Responsiveness proxy from timer + frame probes (not pointer input). + * max(timerDelayMs, largest frame-interval excess over one display period). + */ +function computeMaxInputDelayMs( + timerDelayMs, + frameIntervalsMs, + displayPeriodMs, +) { + let maxExcessFrame = 0; + if (displayPeriodMs > 0) { + for (const interval of frameIntervalsMs) { + maxExcessFrame = Math.max( + maxExcessFrame, + Math.max(0, interval - displayPeriodMs), + ); + } + } + return Math.max(timerDelayMs, maxExcessFrame); +} + +/** Quiet rAF intervals → display period (never hardcode 16.67). */ +async function measureDisplayPeriodMs(samples = 8) { + const intervals = []; + await new Promise(resolve => { + let last = 0; + let n = 0; + const frame = now => { + if (n > 0) intervals.push(now - last); + last = now; + n++; + if (n <= samples) requestAnimationFrame(frame); + else resolve(); + }; + requestAnimationFrame(frame); + }); + return median(intervals); +} + +module.exports = { + median, + frameIntervalsFromTimestamps, + computeMaxInputDelayMs, + measureDisplayPeriodMs, +}; diff --git a/examples/gc-shared/gc-fixture.d.ts b/examples/gc-shared/gc-fixture.d.ts new file mode 100644 index 000000000000..8c008c0bb057 --- /dev/null +++ b/examples/gc-shared/gc-fixture.d.ts @@ -0,0 +1,123 @@ +import type * as Core from '@data-client/core'; + +import type { CandidateKind, Control, Pattern } from './protocol.js'; + +/** Named pieces of one `@data-client/core` build (Node passes its dist bundle). */ +export interface CoreDeps { + GCPolicy: typeof Core.GCPolicy; + Controller: typeof Core.Controller; + createReducer: typeof Core.createReducer; + initialState: Core.State; + GC: string; +} + +export type HarnessMode = 'scan' | 'reducer' | 'end-to-end'; + +export interface FixtureSpec { + candidateKind: CandidateKind; + pattern: Pattern; + count: number; +} + +export interface EntityPath { + key: string; + pk: string; +} + +export interface GCAction { + type: string; + entities: EntityPath[]; + endpoints: string[]; +} + +export interface ExpectedScalars { + queueEntries: number; + uniqueTargets: number; + expectedEntitiesInAction: number; + expectedEndpointsInAction: number; + expectedUniqueEntityDeletions: number; + expectedEndpointDeletions: number; +} + +/** Timerless policy with a public one-shot sweep (calls protected runSweep). */ +export type BenchmarkGCPolicy = Core.GCPolicy & { + sweep(): void; + readonly entityQueueLength: number; + readonly endpointQueueSize: number; + readonly queueEntries: number; +}; + +export interface Harness { + policy: BenchmarkGCPolicy; + reducer: (state: Core.State, action: any) => Core.State; + expected: ExpectedScalars; + /** Reducer mode only; null otherwise or after releaseObserverRefs(). */ + readonly prebuiltAction: GCAction | null; + getState(): Core.State; + getCapturedAction(): GCAction | null; + clearCapturedAction(): void; + /** Drop captured/prebuilt action and seed; keep the live store. */ + releaseObserverRefs(): void; + /** Reducer mode: hand the seed state to the caller and drop the seed ref. */ + takeReducerState(): Core.State; + dispose(): void; +} + +export interface SampleCounts { + actionCount: number; + deletionCount: number; + queueEntries: number; + uniqueTargets: number; + actionTargetCount: number; +} + +export interface Remaining { + entityDeleted: number; + endpointDeleted: number; + entityRemaining: number; + endpointRemaining: number; +} + +export declare const ENTITY_KEY: 'BenchEntity'; +export declare const ZERO_META: Readonly<{ + date: 0; + fetchedAt: 0; + expiresAt: 0; +}>; +export declare function queueCandidates( + policy: Core.GCPolicy, + spec: FixtureSpec, +): ExpectedScalars; +export declare function countRemaining( + state: Core.State, + spec: FixtureSpec, + expected: ExpectedScalars, +): Remaining; + +export interface GCFixture { + ENTITY_KEY: typeof ENTITY_KEY; + ZERO_META: typeof ZERO_META; + BenchmarkGCPolicy: new () => BenchmarkGCPolicy; + buildEntityState(count: number): Core.State; + buildEndpointState(count: number): Core.State; + buildMixedState( + entityCount: number, + endpointCount: number, + ): Core.State; + buildStateForSpec(spec: FixtureSpec): Core.State; + queueCandidates: typeof queueCandidates; + buildPrebuiltAction(spec: FixtureSpec): GCAction; + createHarness(spec: FixtureSpec, mode: HarnessMode): Harness; + countRemaining: typeof countRemaining; + /** Throws when a sample disagrees with fixture expectations. */ + validateSample( + spec: FixtureSpec, + harness: Harness, + sample: SampleCounts, + mode: HarnessMode, + control: Control, + ): void; + GC: string; +} + +export declare function createGCFixture(core: CoreDeps): GCFixture; diff --git a/examples/gc-shared/gc-fixture.js b/examples/gc-shared/gc-fixture.js new file mode 100644 index 000000000000..a32f8b7ebcbd --- /dev/null +++ b/examples/gc-shared/gc-fixture.js @@ -0,0 +1,514 @@ +'use strict'; +/** + * Deterministic GC fixtures shared by the Node, browser, and Android GC harnesses. + * + * Builds state + candidate queues outside timed work. Fixtures match + * entities/entitiesMeta and endpoints/meta so sweeps are eligible immediately + * under BenchmarkGCPolicy (expiresAt → 0). + * + * CommonJS on purpose (see ./README.md). `@data-client/core` is injected via + * createGCFixture() so each harness supplies its own build of core (Node: the + * webpack dist bundle; browser/Android: the package source). + */ +const { splitMixedCount } = require('./protocol.js'); + +const ENTITY_KEY = 'BenchEntity'; +const ZERO_META = Object.freeze({ + date: 0, + fetchedAt: 0, + expiresAt: 0, +}); + +function entityPath(pk) { + return { key: ENTITY_KEY, pk: String(pk) }; +} + +function endpointKey(i) { + return `bench-endpoint-${i}`; +} + +/** Scalar expectations only — no retained path/key arrays (avoids observer heap). */ +function expectedScalars(overrides) { + return { + queueEntries: overrides.queueEntries, + uniqueTargets: overrides.uniqueTargets, + expectedEntitiesInAction: overrides.expectedEntitiesInAction, + expectedEndpointsInAction: overrides.expectedEndpointsInAction, + expectedUniqueEntityDeletions: overrides.expectedUniqueEntityDeletions, + expectedEndpointDeletions: overrides.expectedEndpointDeletions, + }; +} + +/** + * Queue candidates via createCountRef mount/release (outside timed work). + * Duplicate pattern repeatedly releases one entity path. + * Does not retain parallel target arrays — callers that need a prebuilt GC + * action use buildPrebuiltAction(). + */ +function queueCandidates(policy, spec) { + const { candidateKind, pattern, count } = spec; + + if (pattern === 'duplicate') { + if (candidateKind !== 'entity') { + throw new Error( + `duplicate pattern only supports candidateKind=entity (got ${candidateKind})`, + ); + } + const path = entityPath(0); + const countRef = policy.createCountRef({ paths: [path] }); + for (let i = 0; i < count; i++) { + const release = countRef(); + release(); + } + return expectedScalars({ + queueEntries: count, + uniqueTargets: 1, + expectedEntitiesInAction: count, + expectedEndpointsInAction: 0, + expectedUniqueEntityDeletions: 1, + expectedEndpointDeletions: 0, + }); + } + + if (candidateKind === 'entity') { + for (let i = 0; i < count; i++) { + const release = policy.createCountRef({ paths: [entityPath(i)] })(); + release(); + } + return expectedScalars({ + queueEntries: count, + uniqueTargets: count, + expectedEntitiesInAction: count, + expectedEndpointsInAction: 0, + expectedUniqueEntityDeletions: count, + expectedEndpointDeletions: 0, + }); + } + + if (candidateKind === 'endpoint') { + for (let i = 0; i < count; i++) { + const release = policy.createCountRef({ key: endpointKey(i) })(); + release(); + } + return expectedScalars({ + queueEntries: count, + uniqueTargets: count, + expectedEntitiesInAction: 0, + expectedEndpointsInAction: count, + expectedUniqueEntityDeletions: 0, + expectedEndpointDeletions: count, + }); + } + + if (candidateKind === 'mixed') { + const { entities, endpoints } = splitMixedCount(count); + for (let i = 0; i < entities; i++) { + const release = policy.createCountRef({ paths: [entityPath(i)] })(); + release(); + } + for (let i = 0; i < endpoints; i++) { + const release = policy.createCountRef({ key: endpointKey(i) })(); + release(); + } + return expectedScalars({ + queueEntries: count, + uniqueTargets: count, + expectedEntitiesInAction: entities, + expectedEndpointsInAction: endpoints, + expectedUniqueEntityDeletions: entities, + expectedEndpointDeletions: endpoints, + }); + } + + throw new Error(`unknown candidateKind: ${candidateKind}`); +} + +/** Count deletions vs. fixture after a destructive GC apply. */ +function countRemaining(state, spec, expected) { + const bucket = state.entities?.[ENTITY_KEY]; + const entityRemaining = bucket ? Object.keys(bucket).length : 0; + const endpointRemaining = + state.endpoints ? Object.keys(state.endpoints).length : 0; + + // unique/mixed start with expectedUniqueEntityDeletions entities; duplicate starts with 1 + const startedEntities = + spec.pattern === 'duplicate' ? 1 : expected.expectedUniqueEntityDeletions; + const startedEndpoints = expected.expectedEndpointDeletions; + + return { + entityDeleted: startedEntities - entityRemaining, + endpointDeleted: startedEndpoints - endpointRemaining, + entityRemaining, + endpointRemaining, + }; +} + +/** + * Bind the fixture to one build of `@data-client/core`. + * + * Takes named pieces (not the module namespace) so bundlers can tree-shake + * the caller's core import. + */ +function createGCFixture({ + GCPolicy, + Controller, + createReducer, + initialState, + GC, +}) { + /** Timerless explicit policy: no intervals/idleness; one-shot public sweep. */ + class BenchmarkGCPolicy extends GCPolicy { + constructor() { + super({ expiresAt: () => 0 }); + } + + init(controller) { + this.controller = controller; + } + + cleanup() {} + + /** Public one-shot entry that calls the protected monolithic runSweep. */ + sweep() { + this.runSweep(); + } + + get entityQueueLength() { + return this.entitiesQ.length; + } + + get endpointQueueSize() { + return this.endpointsQ.size; + } + + get queueEntries() { + return this.entityQueueLength + this.endpointQueueSize; + } + } + + /** Build store state with matching entities + entitiesMeta. */ + function buildEntityState(count) { + const entities = { [ENTITY_KEY]: {} }; + const entitiesMeta = { [ENTITY_KEY]: {} }; + for (let i = 0; i < count; i++) { + const pk = String(i); + entities[ENTITY_KEY][pk] = { id: pk }; + entitiesMeta[ENTITY_KEY][pk] = { ...ZERO_META }; + } + return { + ...initialState, + entities, + entitiesMeta, + }; + } + + /** Build store state with matching endpoints + meta. */ + function buildEndpointState(count) { + const endpoints = {}; + const meta = {}; + for (let i = 0; i < count; i++) { + const key = endpointKey(i); + endpoints[key] = String(i); + meta[key] = { ...ZERO_META }; + } + return { + ...initialState, + endpoints, + meta, + }; + } + + /** Mixed state: entityCount distinct entities + endpointCount distinct endpoints. */ + function buildMixedState(entityCount, endpointCount) { + const state = buildEntityState(entityCount); + const endpoints = {}; + const meta = {}; + for (let i = 0; i < endpointCount; i++) { + const key = endpointKey(i); + endpoints[key] = String(i); + meta[key] = { ...ZERO_META }; + } + return { ...state, endpoints, meta }; + } + + /** Build a GC action for reducer mode only (scan/e2e get targets from sweep). */ + function buildPrebuiltAction(spec) { + const { candidateKind, pattern, count } = spec; + + if (pattern === 'duplicate') { + const path = entityPath(0); + return { + type: GC, + entities: Array.from({ length: count }, () => ({ ...path })), + endpoints: [], + }; + } + + if (candidateKind === 'entity') { + return { + type: GC, + entities: Array.from({ length: count }, (_, i) => entityPath(i)), + endpoints: [], + }; + } + + if (candidateKind === 'endpoint') { + return { + type: GC, + entities: [], + endpoints: Array.from({ length: count }, (_, i) => endpointKey(i)), + }; + } + + if (candidateKind === 'mixed') { + const { entities, endpoints } = splitMixedCount(count); + return { + type: GC, + entities: Array.from({ length: entities }, (_, i) => entityPath(i)), + endpoints: Array.from({ length: endpoints }, (_, i) => endpointKey(i)), + }; + } + + throw new Error(`unknown candidateKind: ${candidateKind}`); + } + + function buildStateForSpec(spec) { + const { candidateKind, pattern, count } = spec; + if (pattern === 'duplicate') { + return buildEntityState(1); + } + if (candidateKind === 'entity') return buildEntityState(count); + if (candidateKind === 'endpoint') return buildEndpointState(count); + if (candidateKind === 'mixed') { + const { entities, endpoints } = splitMixedCount(count); + return buildMixedState(entities, endpoints); + } + throw new Error(`unknown candidateKind: ${candidateKind}`); + } + + /** + * Wire a Controller + BenchmarkGCPolicy for the given mode. + * - scan: dispatch captures action only; no prebuilt action arrays + * - end-to-end: dispatch reduces; no prebuilt action arrays + * - reducer: prebuilt GC action only (needed for timed reducer path) + */ + function createHarness(spec, mode) { + const state = buildStateForSpec(spec); + const policy = new BenchmarkGCPolicy(); + const controller = new Controller({ gcPolicy: policy }); + const reducer = createReducer(controller); + + let capturedAction = null; + let workingState = state; + /** Seed retained only until reducer takes it (then nulled). */ + let seedState = mode === 'reducer' ? state : null; + + controller.getState = () => workingState; + + if (mode === 'scan') { + controller.dispatch = action => { + capturedAction = action; + }; + } else if (mode === 'end-to-end') { + controller.dispatch = action => { + capturedAction = action; + workingState = reducer(workingState, action); + }; + } else if (mode === 'reducer') { + // Reducer mode does not sweep; dispatch unused during timed work + controller.dispatch = action => { + capturedAction = action; + }; + } else { + throw new Error(`unknown harness mode: ${mode}`); + } + + policy.init(controller); + const expected = queueCandidates(policy, spec); + + if (policy.queueEntries !== expected.queueEntries) { + throw new Error( + `fixture queue cardinality ${policy.queueEntries} !== expected ${expected.queueEntries}`, + ); + } + + // Only reducer needs a prebuilt action; scan/e2e would duplicate 100k target arrays + let prebuiltAction = mode === 'reducer' ? buildPrebuiltAction(spec) : null; + + const harness = { + policy, + reducer, + expected, + get prebuiltAction() { + return prebuiltAction; + }, + getState: () => workingState, + getCapturedAction: () => capturedAction, + clearCapturedAction() { + capturedAction = null; + }, + /** + * Drop observer-only refs (captured action, prebuilt action) while keeping + * the live store + policy for retained-cache heap measurement. + */ + releaseObserverRefs() { + capturedAction = null; + prebuiltAction = null; + seedState = null; + }, + /** + * Reducer: hand the freshly built seed to the reducer and drop the seed + * ref. The fixture is built per harness, so there is nothing to protect + * by cloning. + */ + takeReducerState() { + if (!seedState) { + throw new Error( + 'takeReducerState() requires reducer mode with a seed', + ); + } + workingState = seedState; + seedState = null; + return workingState; + }, + /** Drop store/policy/controller so a later V8 GC can reclaim everything. */ + dispose() { + capturedAction = null; + prebuiltAction = null; + seedState = null; + workingState = initialState; + controller.getState = () => initialState; + controller.dispatch = () => Promise.resolve(); + policy.cleanup(); + }, + }; + + return harness; + } + + /** + * Validate an accepted sample against fixture expectations. + * Throws on invalid fixture/result. + */ + function validateSample(spec, harness, sampleMetrics, mode, control) { + const { expected, policy, getCapturedAction, getState } = harness; + + if (control === 'no-gc') { + if (sampleMetrics.actionCount !== 0) { + throw new Error( + `no-gc control expected actionCount 0, got ${sampleMetrics.actionCount}`, + ); + } + if (sampleMetrics.deletionCount !== 0) { + throw new Error( + `no-gc control expected deletionCount 0, got ${sampleMetrics.deletionCount}`, + ); + } + return; + } + + if (sampleMetrics.queueEntries !== expected.queueEntries) { + throw new Error( + `queueEntries ${sampleMetrics.queueEntries} !== expected ${expected.queueEntries}`, + ); + } + if (sampleMetrics.uniqueTargets !== expected.uniqueTargets) { + throw new Error( + `uniqueTargets ${sampleMetrics.uniqueTargets} !== expected ${expected.uniqueTargets}`, + ); + } + + const actionTargetCount = + expected.expectedEntitiesInAction + expected.expectedEndpointsInAction; + if (sampleMetrics.actionTargetCount !== actionTargetCount) { + throw new Error( + `actionTargetCount ${sampleMetrics.actionTargetCount} !== expected ${actionTargetCount}`, + ); + } + + if (mode === 'scan' || mode === 'end-to-end') { + const action = getCapturedAction(); + if (!action || action.type !== GC) { + throw new Error('expected a GC action to be dispatched'); + } + if (action.entities.length !== expected.expectedEntitiesInAction) { + throw new Error( + `action.entities.length ${action.entities.length} !== ${expected.expectedEntitiesInAction}`, + ); + } + if (action.endpoints.length !== expected.expectedEndpointsInAction) { + throw new Error( + `action.endpoints.length ${action.endpoints.length} !== ${expected.expectedEndpointsInAction}`, + ); + } + if (spec.pattern === 'duplicate') { + const unique = new Set(action.entities.map(p => `${p.key}:${p.pk}`)); + if (unique.size !== 1) { + throw new Error( + `duplicate pattern expected 1 unique entity path in action, got ${unique.size}`, + ); + } + } + } + + if (mode === 'reducer' || mode === 'end-to-end') { + const expectedDeletions = + expected.expectedUniqueEntityDeletions + + expected.expectedEndpointDeletions; + if (sampleMetrics.deletionCount !== expectedDeletions) { + throw new Error( + `deletionCount ${sampleMetrics.deletionCount} !== expected ${expectedDeletions}`, + ); + } + const remaining = countRemaining(getState(), spec, expected); + if ( + remaining.entityRemaining !== 0 || + remaining.endpointRemaining !== 0 + ) { + throw new Error( + `expected empty GC targets after deletion; remaining entities=${remaining.entityRemaining} endpoints=${remaining.endpointRemaining}`, + ); + } + } + + if (mode === 'scan' && sampleMetrics.deletionCount !== 0) { + throw new Error( + `scan mode must not delete; got ${sampleMetrics.deletionCount}`, + ); + } + + // After a successful sweep, queues should be drained (gc path). + if ( + (mode === 'scan' || mode === 'end-to-end') && + policy.queueEntries !== 0 + ) { + throw new Error( + `expected empty queues after sweep, got ${policy.queueEntries}`, + ); + } + } + + return { + ENTITY_KEY, + ZERO_META, + BenchmarkGCPolicy, + buildEntityState, + buildEndpointState, + buildMixedState, + buildStateForSpec, + queueCandidates, + buildPrebuiltAction, + createHarness, + countRemaining, + validateSample, + GC, + }; +} + +module.exports = { + ENTITY_KEY, + ZERO_META, + queueCandidates, + countRemaining, + createGCFixture, +}; diff --git a/examples/gc-shared/protocol-cli.js b/examples/gc-shared/protocol-cli.js new file mode 100644 index 000000000000..a27b0424061a --- /dev/null +++ b/examples/gc-shared/protocol-cli.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +'use strict'; +/** + * Host CLI over ./protocol.js for shell scripts (benchmark-native run-matrix / + * collect-report). Never imported by app bundles. + * + * node protocol-cli.js list [filter] + * Prints matching stable scenario ids, one per line. + * node protocol-cli.js validate [samples] + * Exits 0 when valid; otherwise prints `error: ...` to stderr and exits 1. + */ +const { + listScenarios, + validateAxes, + validateSampleCount, +} = require('./protocol.js'); + +/** Env var names the shell scripts accept, used in validation messages. */ +const ENV_LABELS = { + candidateKind: 'CANDIDATE_KIND', + pattern: 'PATTERN', + count: 'COUNT', + control: 'CONTROL', + samples: 'SAMPLES', + oneOf: '', +}; + +/** Decimal integer strings only (no sign, exponent, or leading zeros). */ +function parseIntArg(raw) { + return /^(0|[1-9][0-9]*)$/.test(raw) ? Number(raw) : raw; +} + +function main(argv) { + const [cmd, ...rest] = argv; + if (cmd === 'list') { + const [platform, filter] = rest; + for (const { id } of listScenarios(platform, { filter })) { + process.stdout.write(`${id}\n`); + } + return 0; + } + if (cmd === 'validate') { + const [candidateKind, pattern, count, control, samples = '1'] = rest; + validateAxes( + { candidateKind, pattern, count: parseIntArg(count ?? ''), control }, + ENV_LABELS, + ); + validateSampleCount(parseIntArg(samples), ENV_LABELS); + return 0; + } + process.stderr.write( + 'usage: protocol-cli.js list [filter]\n' + + ' protocol-cli.js validate [samples]\n', + ); + return 2; +} + +try { + process.exitCode = main(process.argv.slice(2)); +} catch (err) { + process.stderr.write(`error: ${err.message}\n`); + process.exitCode = 1; +} diff --git a/examples/gc-shared/protocol.d.ts b/examples/gc-shared/protocol.d.ts new file mode 100644 index 000000000000..95d91bf00d42 --- /dev/null +++ b/examples/gc-shared/protocol.d.ts @@ -0,0 +1,62 @@ +export type Platform = 'node' | 'browser' | 'android'; +export type CandidateKind = 'entity' | 'endpoint' | 'mixed'; +export type Pattern = 'unique' | 'duplicate'; +export type Control = 'gc' | 'no-gc'; +export type Mode = 'scan' | 'reducer' | 'end-to-end' | 'interaction'; + +export interface ScenarioAxesInput { + candidateKind: CandidateKind; + pattern: Pattern; + count: number; + control: Control; +} + +export interface ScenarioAxes extends ScenarioAxesInput { + platform: Platform; + mode: Mode; +} + +export interface ScenarioDescriptor extends ScenarioAxes { + id: string; +} + +/** Field names in validation messages, plus the prefix before allowed values. */ +export type AxisLabels = Record< + 'candidateKind' | 'pattern' | 'count' | 'control' | 'samples' | 'oneOf', + string +>; + +export declare const CANONICAL_COUNTS: readonly [1000, 10000, 100000]; +export declare const CANDIDATE_KINDS: readonly CandidateKind[]; +export declare const PATTERNS: readonly Pattern[]; +export declare const CONTROLS: readonly Control[]; +export declare const MODES_BY_PLATFORM: { + readonly node: readonly ['scan', 'reducer', 'end-to-end']; + readonly browser: readonly ['end-to-end']; + readonly android: readonly ['interaction']; +}; +export declare const MAX_SAMPLES: 50; +export declare const AXIS_LABELS: AxisLabels; + +/** Throws on invalid axes; does not coerce. */ +export declare function validateAxes( + axes: ScenarioAxesInput, + labels?: AxisLabels, +): void; +export declare function validateSampleCount( + samples: number, + labels?: AxisLabels, +): void; +/** `{platform}/{candidateKind}/{pattern}/{count}/{mode}/{control}`; throws on invalid axes. */ +export declare function scenarioId(axes: ScenarioAxes): string; +export declare function parseScenarioId(id: string): ScenarioAxes; +/** Empty → all; `^text` → prefix; otherwise slash-bounded contiguous segments. */ +export declare function matchScenarioId(id: string, filter?: string): boolean; +export declare function listScenarios( + platform: Platform, + options?: { filter?: string }, +): ScenarioDescriptor[]; +export declare function splitMixedCount(total: number): { + entities: number; + endpoints: number; +}; diff --git a/examples/gc-shared/protocol.js b/examples/gc-shared/protocol.js new file mode 100644 index 000000000000..fb75ab0e6b27 --- /dev/null +++ b/examples/gc-shared/protocol.js @@ -0,0 +1,225 @@ +'use strict'; +/** + * GC measurement protocol shared by the Node, browser, and Android harnesses: + * scenario axes, validation, stable scenario ids, and scenario filtering. + * + * Stable id: `{platform}/{candidateKind}/{pattern}/{count}/{mode}/{control}`. + * + * CommonJS on purpose (see ./README.md). Bundled into browser/Android apps, so + * no Node globals here; the host CLI lives in ./protocol-cli.js. + */ + +const CANONICAL_COUNTS = [1_000, 10_000, 100_000]; +const CANDIDATE_KINDS = ['entity', 'endpoint', 'mixed']; +const PATTERNS = ['unique', 'duplicate']; +const CONTROLS = ['gc', 'no-gc']; +const MODES_BY_PLATFORM = { + node: ['scan', 'reducer', 'end-to-end'], + browser: ['end-to-end'], + android: ['interaction'], +}; +const PLATFORMS = Object.keys(MODES_BY_PLATFORM); + +/** Positive sample counts capped to keep accidental huge runs from launching. */ +const MAX_SAMPLES = 50; + +/** + * Wording of validation messages: field names plus the prefix before an + * allowed-value list. Callers may pass their own (the host CLI uses env var + * names like CANDIDATE_KIND and no `one of` prefix). + */ +const AXIS_LABELS = { + candidateKind: 'candidateKind', + pattern: 'pattern', + count: 'count', + control: 'control', + samples: 'samples', + oneOf: 'one of ', +}; + +function invalid(labels, field, value, expected) { + return new Error( + `invalid ${labels[field]}=${String(value)}; expected ${expected}`, + ); +} + +/** + * Validate scenario axes. Throws on invalid input; does not coerce. + * `duplicate` is entity-only (one path released `count` times). + */ +function validateAxes(axes, labels = AXIS_LABELS) { + const { candidateKind, pattern, count, control } = axes; + if (!CANDIDATE_KINDS.includes(candidateKind)) { + throw invalid( + labels, + 'candidateKind', + candidateKind, + `${labels.oneOf}${CANDIDATE_KINDS.join('|')}`, + ); + } + if (!PATTERNS.includes(pattern)) { + throw invalid( + labels, + 'pattern', + pattern, + `${labels.oneOf}${PATTERNS.join('|')}`, + ); + } + if (!CONTROLS.includes(control)) { + throw invalid( + labels, + 'control', + control, + `${labels.oneOf}${CONTROLS.join('|')}`, + ); + } + if (!Number.isInteger(count) || !CANONICAL_COUNTS.includes(count)) { + throw invalid( + labels, + 'count', + count, + `${labels.oneOf}${CANONICAL_COUNTS.join('|')}`, + ); + } + if (pattern === 'duplicate' && candidateKind !== 'entity') { + throw new Error( + `duplicate pattern only supports ${labels.candidateKind}=entity (got ${candidateKind})`, + ); + } +} + +function validateSampleCount(samples, labels = AXIS_LABELS) { + if (!Number.isInteger(samples) || samples < 1 || samples > MAX_SAMPLES) { + throw invalid(labels, 'samples', samples, `integer 1..${MAX_SAMPLES}`); + } +} + +function validatePlatformMode(platform, mode) { + const modes = MODES_BY_PLATFORM[platform]; + if (!modes) { + throw new Error( + `invalid platform=${String(platform)}; expected one of ${PLATFORMS.join('|')}`, + ); + } + if (!modes.includes(mode)) { + throw new Error( + `invalid mode=${String(mode)} for platform=${platform}; expected one of ${modes.join('|')}`, + ); + } +} + +/** Stable scenario id from validated axes (throws on invalid axes). */ +function scenarioId(axes) { + const { platform, candidateKind, pattern, count, mode, control } = axes; + validatePlatformMode(platform, mode); + validateAxes(axes); + return [platform, candidateKind, pattern, String(count), mode, control].join( + '/', + ); +} + +/** Parse a stable id back to axes (throws on malformed or invalid ids). */ +function parseScenarioId(id) { + const parts = String(id).split('/'); + if (parts.length !== 6) { + throw new Error(`malformed GC scenario id: ${id}`); + } + const axes = { + platform: parts[0], + candidateKind: parts[1], + pattern: parts[2], + count: Number(parts[3]), + mode: parts[4], + control: parts[5], + }; + if (scenarioId(axes) !== id) { + throw new Error(`GC scenario id failed revalidation: ${id}`); + } + return axes; +} + +/** + * Scenario filter against a stable id. + * - empty: matches everything + * - `^text`: id starts with `text` + * - otherwise: contiguous slash-bounded segments. One leading and one + * trailing slash are removed, so `/100000/` matches the 100000 segment + * only, and `1000` does not match `10000` or `100000`. + */ +function matchScenarioId(id, filter) { + if (!filter) return true; + if (filter.startsWith('^')) return id.startsWith(filter.slice(1)); + let needle = filter; + if (needle.startsWith('/')) needle = needle.slice(1); + if (needle.endsWith('/')) needle = needle.slice(0, -1); + if (needle.length === 0) return true; + return `/${id}/`.includes(`/${needle}/`); +} + +/** + * Every supported scenario for a platform, in that platform's run order: + * - node/browser: count → candidateKind → mode → control, then the entity + * duplicate baseline per count + * - android: candidateKind → count → control, then all duplicate rows + * (device matrix order) + * Only descriptors — no fixtures are built here. + */ +function listScenarios(platform, { filter } = {}) { + const modes = MODES_BY_PLATFORM[platform]; + if (!modes) { + throw new Error( + `invalid platform=${String(platform)}; expected one of ${PLATFORMS.join('|')}`, + ); + } + const scenarios = []; + const add = (candidateKind, pattern, count) => { + for (const mode of modes) { + for (const control of CONTROLS) { + const axes = { platform, candidateKind, pattern, count, mode, control }; + const id = scenarioId(axes); + if (matchScenarioId(id, filter)) scenarios.push({ id, ...axes }); + } + } + }; + + if (platform === 'android') { + for (const candidateKind of CANDIDATE_KINDS) { + for (const count of CANONICAL_COUNTS) add(candidateKind, 'unique', count); + } + for (const count of CANONICAL_COUNTS) add('entity', 'duplicate', count); + } else { + for (const count of CANONICAL_COUNTS) { + for (const candidateKind of CANDIDATE_KINDS) { + add(candidateKind, 'unique', count); + } + add('entity', 'duplicate', count); + } + } + return scenarios; +} + +/** + * Split mixed total into entity + endpoint counts (entities get the remainder + * when odd so entityCount + endpointCount === total). + */ +function splitMixedCount(total) { + const endpoints = Math.floor(total / 2); + return { entities: total - endpoints, endpoints }; +} + +module.exports = { + CANONICAL_COUNTS, + CANDIDATE_KINDS, + PATTERNS, + CONTROLS, + MODES_BY_PLATFORM, + MAX_SAMPLES, + AXIS_LABELS, + validateAxes, + validateSampleCount, + scenarioId, + parseScenarioId, + matchScenarioId, + listScenarios, + splitMixedCount, +}; diff --git a/examples/gc-shared/stats.d.ts b/examples/gc-shared/stats.d.ts new file mode 100644 index 000000000000..ef676e9fa5da --- /dev/null +++ b/examples/gc-shared/stats.d.ts @@ -0,0 +1,13 @@ +export interface NumberSummary { + median: number; + min: number; + max: number; + p95: number; + p99: number; +} + +/** Linear-interpolated percentile over an ascending-sorted array. */ +export declare function percentile(sorted: number[], p: number): number; +export declare function summarizeNumbers( + values: number[], +): NumberSummary | null; diff --git a/examples/gc-shared/stats.js b/examples/gc-shared/stats.js new file mode 100644 index 000000000000..4c7572824470 --- /dev/null +++ b/examples/gc-shared/stats.js @@ -0,0 +1,33 @@ +'use strict'; +/** + * Report summaries shared by the Node, browser, and Android GC reports. + * CommonJS on purpose (see ./README.md). + */ + +/** Linear-interpolated percentile over an ascending-sorted array. */ +function percentile(sorted, p) { + if (sorted.length === 1) return sorted[0]; + const idx = (p / 100) * (sorted.length - 1); + const lo = Math.floor(idx); + const hi = Math.ceil(idx); + if (lo === hi) return sorted[lo]; + const w = idx - lo; + return sorted[lo] * (1 - w) + sorted[hi] * w; +} + +function summarizeNumbers(values) { + if (values.length === 0) return null; + const sorted = values.slice().sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const median = + sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; + return { + median, + min: sorted[0], + max: sorted[sorted.length - 1], + p95: percentile(sorted, 95), + p99: percentile(sorted, 99), + }; +} + +module.exports = { percentile, summarizeNumbers }; diff --git a/package.json b/package.json index e6518e69d7c5..3da16df82b63 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "packages/*", "examples/benchmark", "examples/benchmark-react", + "examples/benchmark-native", "examples/test-bundlesize", "examples/normalizr-*", "examples/coin-app", diff --git a/packages/core/src/state/__tests__/GCPolicy-dangling.ts b/packages/core/src/state/__tests__/GCPolicy-dangling.ts new file mode 100644 index 000000000000..b73f04400400 --- /dev/null +++ b/packages/core/src/state/__tests__/GCPolicy-dangling.ts @@ -0,0 +1,101 @@ +import { Endpoint, Entity } from '@data-client/endpoint'; +import { jest } from '@jest/globals'; + +import { ExpiryStatus } from '../..'; +import Controller from '../../controller/Controller'; +import type { ActionTypes, State } from '../../types'; +import { GCPolicy } from '../GCPolicy'; +import createReducer, { initialState } from '../reducer/createReducer'; + +/** Characterization of today's GC deletion model, not a statement of + * desired behavior. + * + * GCPolicy counts references per mounted consumer. An endpoint result that + * was written but never counted (prefetch, `controller.set*`, hydration) is + * never queued, so it survives. An entity it names can still be swept when + * a different consumer releases it. The surviving result then denormalizes + * without that entity while its expiry stays `Valid`, so nothing refetches. + * + * See plans/garbage-collection.md, "Open decisions" 1. + */ +describe('GCPolicy deletes entities still named by uncounted endpoints', () => { + class Article extends Entity { + id = 0; + title = ''; + } + const getList = new Endpoint(() => Promise.resolve(), { + key: () => 'articles', + schema: [Article], + }); + const getDetail = new Endpoint((_: { id: number }) => Promise.resolve(), { + key: ({ id }) => `article ${id}`, + schema: Article, + }); + + let state: State; + let controller: Controller; + let gcPolicy: GCPolicy; + const now = 1_000_000; + + beforeEach(() => { + jest.useFakeTimers({ now }); + const reducer = createReducer(new Controller()); + state = initialState; + gcPolicy = new GCPolicy({ expiresAt: () => 0 }); + controller = new Controller({ + gcPolicy, + getState: () => state, + dispatch: (action: ActionTypes) => { + state = reducer(state, action); + return Promise.resolve(); + }, + }); + gcPolicy.init(controller); + + // list is written but never mounted; detail 1 is written too + controller.setResponse(getList, [ + { id: 1, title: 'one' }, + { id: 2, title: 'two' }, + ]); + controller.setResponse(getDetail, { id: 1 }, { id: 1, title: 'one' }); + }); + + afterEach(() => { + gcPolicy.cleanup(); + jest.useRealTimers(); + }); + + function mountDetailThenUnmountAndSweep() { + const meta = controller.getResponseMeta(getDetail, { id: 1 }, state); + const release = meta.countRef(); + release(); + gcPolicy['runSweep'](); + } + + it('list shrinks and stays Valid after a detail consumer releases an entity', () => { + expect(controller.getResponseMeta(getList, state).data).toHaveLength(2); + + mountDetailThenUnmountAndSweep(); + + expect(state.entities[Article.key]?.['1']).toBeUndefined(); + expect(state.endpoints[getList.key()]).toEqual(['1', '2']); + + const list = controller.getResponseMeta(getList, state); + expect(list.data).toEqual([expect.objectContaining({ id: 2 })]); + expect(list.expiryStatus).toBe(ExpiryStatus.Valid); + }); + + it('a surviving detail result is undefined and still Valid after a list consumer releases its entity', () => { + const meta = controller.getResponseMeta(getList, state); + meta.countRef()(); + gcPolicy['runSweep'](); + + expect(state.endpoints[getList.key()]).toBeUndefined(); + expect(state.entities[Article.key]?.['1']).toBeUndefined(); + expect(state.endpoints[getDetail.key({ id: 1 })]).toBe('1'); + + const detail = controller.getResponseMeta(getDetail, { id: 1 }, state); + expect(detail.data).toBeUndefined(); + expect(detail.expiryStatus).toBe(ExpiryStatus.Valid); + }); +}); diff --git a/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx b/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx new file mode 100644 index 000000000000..91fab4110ae4 --- /dev/null +++ b/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx @@ -0,0 +1,90 @@ +import { GCPolicy } from '@data-client/core'; +import { DataProvider, useController, useSuspense } from '@data-client/react'; +import { act, render, waitFor } from '@testing-library/react'; +import { makeGetTodo, mockTodoState, Todo } from '__tests__/concurrentFixtures'; +import React, { Suspense, useLayoutEffect, useState } from 'react'; + +/** Characterization of today's GC timing, not a statement of desired + * behavior. + * + * Read hooks increment GC refs in a passive effect. A sweep that runs after + * a consumer commits but before that effect sees a zero count and queues + * deletion. The reducer does not consult counts, so the entity is deleted + * while the consumer is mounted. GC returns the same state object, so the + * consumer keeps rendering stale data until an unrelated update, then + * suspends and refetches. + * + * The sweep runs in a sibling layout effect to land deterministically in + * that window. In an app it would be an idle callback that fires between a + * non-sync commit and React's scheduled passive-effect flush. + * + * See plans/garbage-collection.md, "Open decisions" 3. + */ +describe('GC sweep between consumer commit and countRef', () => { + it('deletes a mounted consumer entity, which later suspends and refetches', async () => { + const fetchTodo = jest.fn(async ({ id }: { id: string }) => ({ + id, + title: `fetched ${id}`, + })); + const getTodo = makeGetTodo(fetchTodo); + const gcPolicy = new GCPolicy({ expiresAt: () => 0 }); + let sweepOnLayout = false; + let setShow!: (show: boolean) => void; + let controller!: ReturnType; + + function TodoTitle() { + const todo = useSuspense(getTodo, { id: '1' }); + return
{todo.title}
; + } + function SweepInLayout() { + useLayoutEffect(() => { + if (sweepOnLayout) gcPolicy['runSweep'](); + }, []); + return null; + } + function App() { + const [show, set] = useState(true); + setShow = set; + controller = useController(); + return show ? + <> + + + + : null; + } + + const { container } = render( + + + + + , + ); + expect(container.textContent).toBe('todo 1'); + + // unmount queues Todo 1 and its endpoint for collection + act(() => setShow(false)); + + act(() => { + sweepOnLayout = true; + setShow(true); + }); + + // consumer is mounted and counted, but its entity is gone + expect(container.textContent).toBe('todo 1'); + expect(gcPolicy['entityCount'].get(Todo.key)?.get('1')).toBe(1); + expect(controller.getState().entities[Todo.key]?.['1']).toBeUndefined(); + expect(fetchTodo).not.toHaveBeenCalled(); + + // any unrelated update exposes the deletion to the mounted consumer + await act(async () => { + controller.setResponse(getTodo, { id: '2' }, { id: '2', title: 'two' }); + }); + expect(fetchTodo).toHaveBeenCalledTimes(1); + await waitFor(() => expect(container.textContent).toBe('fetched 1')); + }); +}); diff --git a/plans/garbage-collection.md b/plans/garbage-collection.md index 42b4375ed7ba..378ec8b33d12 100644 --- a/plans/garbage-collection.md +++ b/plans/garbage-collection.md @@ -1,461 +1,343 @@ -# Garbage Collection Direction +# Garbage collection -Status: draft -Owner: TBD +Status: exploring. No design is chosen, and production GC is unchanged. Related: [GOALS.md](../GOALS.md), `packages/core/src/state/GCPolicy.ts` -## Priorities - -Garbage collection must follow the project-wide performance goals and the -platform priorities adopted for this work: - -1. Preserve a continuous 60 FPS experience on mid-range hardware. Collection - must not create blocking stutters during critical updates or interactions. -2. For this work, web prioritizes bundle size before retained cache memory. -3. For this work, React Native prioritizes retained memory before bundle size. -4. Continue minimizing network work. More aggressive collection can cause - refetches, so lower memory use is not automatically a net performance win. -5. Keep the default path understandable, dependency-light, and portable across - React, React Native, Vue, and imperative `Controller` use. - -These priorities imply different platform defaults or adapters over one shared -correctness model. They do not imply different endpoint or schema definitions. - -## Current system - -The current policy is reference-counted and age-gated: - -1. Read hooks obtain a `countRef` from `Controller.getResponseMeta()` or - `Controller.getQueryMeta()`. -2. Mounting increments references for the endpoint key and every entity path - used by the denormalized result. -3. Releasing the last reference queues those endpoint and entity candidates. -4. By default, every five minutes, `GCPolicy` schedules a sweep. -5. A queued candidate is eligible when it is still unreferenced and its GC - expiry has passed. -6. One `GC` action deletes all eligible endpoint results, entities, and their - metadata in place. - -The default GC expiry is: - -```ts +This is the working record for redesigning cache garbage collection (GC). It +covers the goals, how GC works today, confirmed gaps, prior art, open +decisions, one candidate design, and how to measure. Only the goals and +constraints are settled. Everything from [Candidate design](#candidate-design-cooperative-slicing) +onward is a hypothesis. If a simpler design meets the goals, prefer it. + +## Goals + +In priority order: + +1. **Interaction latency.** No dropped frames during critical updates: a + continuous 60 FPS on mid-range hardware, including while GC runs. +2. **Web:** bundle size, then retained memory. +3. **React Native:** retained memory, then bundle size. Bundle size barely + matters on React Native. +4. **Avoid refetches.** Deleting more aggressively can cause refetches. Lower + memory is not automatically a win. ([GOALS.md](../GOALS.md) ranks network + cost above frame rate in general.) + +Web and React Native may pick different points on this tradeoff. Endpoint and +schema definitions stay shared. + +## Constraints + +- **Keep React's built-in reducer.** It exists for concurrent rendering in + React 19+. Do not replace it with an external store to make GC easier. +- **Keep GC bookkeeping out of rendered state.** Reference counts must not + update render-driving state on every mount and unmount. +- **No render from GC alone.** If GC changes nothing a mounted consumer + renders, it must not by itself trigger a React render. +- **Open question, not an approval:** attaching GC work to a later reducer + update must not slow that update, and the update must be unchanged when + nothing is reclaimable. + +## How GC works today + +**Reference counting.** Read hooks get a `countRef` from +`Controller.getResponseMeta()` or `getQueryMeta()`. Mounting increments the +endpoint key and every entity path in the denormalized result. Releasing the +last reference queues those candidates: endpoints in a `Set`, entities in an +array (so one entity can be queued many times). + +**Sweeping.** Every 5 minutes (`intervalMS`), `GCPolicy` calls +`requestIdleCallback(..., { timeout: 1000 })`, or runs the sweep synchronously +if that API is missing. It ignores `timeRemaining()` and `didTimeout`. The +sweep scans the whole queue and dispatches **one** `GC` action for everything +eligible. A candidate is eligible when unreferenced and past: + +```text max((expiresAt - fetchedAt) * expiryMultiplier, 120_000) + fetchedAt ``` -`expiryMultiplier` defaults to `2`. This is deletion policy, not fetch expiry: -staleness and invalidation may cause a refetch without removing normalized data. - -The policy currently exposes: - -- `intervalMS` -- `expiryMultiplier` -- a custom `expiresAt(meta)` function -- complete replacement through `GCInterface` -- `ImmortalGCPolicy` for no automatic deletion - -React `DataProvider` installs `GCPolicy` by default. Its React Native build -substitutes `NativeGCPolicy`. A bare `Controller` and the Vue provider currently -fall back to `ImmortalGCPolicy` unless a policy is supplied. Imperative use must -also own policy lifecycle: constructing a `Controller` with a `GCPolicy` does -not call `init()` or `cleanup()`; providers do that through `initManager`. - -There is no LRU, maximum entry count, or retained-byte limit. - -## Current limitations - -### Unbounded synchronous work - -`runSweep()` scans the complete endpoint and entity candidate queues. It then -dispatches every eligible deletion in one action, whose reducer synchronously -deletes the complete batch. Both phases are unbounded. - -Idle scheduling only changes when this monolithic work begins. It does not make -the work interruptible. On web: - -- `requestIdleCallback(..., { timeout: 1000 })` may run because the timeout - elapsed even when no meaningful idle budget remains. -- The fallback runs the complete sweep synchronously. -- The callback ignores `IdleDeadline.timeRemaining()` and `didTimeout`. - -A sufficiently large queue can therefore produce a long task and dropped -frames. - -### React Native scheduling is deprecated and unbounded - -`NativeGCPolicy` uses `InteractionManager.runAfterInteractions()` and -`InteractionManager.setDeadline()`. In React Native 0.86, `InteractionManager` -is deprecated, `runAfterInteractions()` is implemented using `setImmediate()`, -and `setDeadline()` is a no-op. It defers a sweep but does not protect an active -interaction or bound the resulting task. - -React Native 0.86 officially recommends refactoring long tasks into smaller -units and scheduling them with `requestIdleCallback()`. That is the supported -migration path and should be the native host primitive. It remains a -cooperative idle hint, not a frame-safety guarantee: once a callback or one GC -unit starts, React Native cannot preempt it. The GC must therefore enforce its -own bounded units, elapsed-time/count budgets, yielding, and cancellation. - -### Candidate queue amplification - -Endpoint candidates use a `Set`, but entity candidates use an array. Repeated -mount/release cycles can queue the same entity multiple times before it becomes -eligible, increasing retained queue memory and future sweep work. - -### Incomplete deletion - -Entity GC removes `entities` and `entitiesMeta` entries but does not remove -corresponding entries from `state.indexes`. This retains stale mappings and -some memory. Correct index cleanup needs a bounded design: blindly scanning all -indexes while deleting one entity could itself create an unbounded unit. +`expiryMultiplier` defaults to `2`. This is deletion, separate from fetch +expiry (`dataExpiryLength`, invalidation, `expireAll`). -### Lifecycle and race gaps - -- `cleanup()` cancels the interval but not an already scheduled idle callback. -- Eligibility is checked before dispatch. A store integration with asynchronous - dispatch can reacquire a candidate before its deletion is reduced. -- Hook references are installed in effects, leaving a render-to-effect window. -- A subscription by itself does not hold a GC reference. `useLive`, which also - reads the result, normally does. - -These need explicit invariants and tests before making collection more -aggressive. - -### Memory cannot be bounded - -Age-based collection gives no hard upper memory bound. Active data is retained, -and unreferenced data remains until both its retention threshold and a sweep -pass. JavaScript also lacks a portable, reliable retained-byte measurement API, -so a byte-precise cross-platform policy is not currently realistic. - -### Configuration and documentation are inconsistent - -React enables GC automatically, while Vue and direct `Controller` usage do not. -The provider option and policy settings have little public documentation. The -behavior should be made deliberate and documented before adding more profiles. - -## Cooperative collection model - -JavaScript cannot suspend arbitrary synchronous work. Frame-safe collection -requires the algorithm to expose bounded, resumable units and the scheduler to -run those units within a budget. - -The reusable part is a small internal scheduling shell: - -- schedule another slice; -- decide when the current slice should yield; -- cap elapsed time and work count; -- make progress after prolonged starvation; -- cancel pending callbacks; -- provide platform-specific scheduling; -- allow deterministic test scheduling. - -The GC-specific part owns: - -- candidate traversal state; -- expiry and reference validation; -- deduplication; -- construction of bounded deletion batches; -- revalidation around dispatch; -- bounded index cleanup. - -For the current implementation, a natural scan unit is checking one endpoint -or entity candidate. Deleting one entity and its metadata is a plausible -mutation unit, but index cleanup is not yet naturally bounded: the current GC -action carries only the entity type and primary key, while `GCPolicy` has no -schema/index-name information. The index design must prove how relevant work is -identified, resumed, and bounded without scanning every index in one step. A -slice should process multiple proven-bounded units only while both time and -count budgets remain. - -This is not graph reference-counting GC. Entities do not own references to their -children, entity deletion does not decrement child references, and there is no -cascading traversal. Consumer reads independently retain all entity paths used -by a denormalized result. Any future graph ownership model would be a separate, -much larger semantic change. - -## Direction - -### 1. Bound scanning and deletion - -Make candidate traversal resumable and cap both: - -- candidates examined per slice; and -- records deleted per dispatched action. - -Time budgets adapt to device speed, while count limits protect against timer -quirks and unexpectedly expensive batches. A single unit must also remain -bounded; the scheduler cannot compensate for one deletion that scans an entire -store or synchronously notifies unbounded work. - -### 2. Use a small internal cooperative scheduler - -Prefer a small internal executor over a runtime dependency: - -- React's `scheduler` package exposes `unstable_` APIs and would couple core - data behavior to a React implementation package. -- generic coroutine or iterator dependencies do not identify domain-specific - safe yield boundaries; -- the required scheduling shell is small compared with the GC correctness - logic; -- avoiding a dependency protects bundle size and wide platform compatibility. - -The scheduler should remain internal initially. Deterministic tests should use -a private test factory or internal host adapter. Constructor injection or a new -protected hook on exported `GCPolicy` would still become observable public and -subclass surface even if `GCInterface` remained unchanged. - -Do not copy a `requestIdleCallback` loop without handling starvation. When a -callback fires with `didTimeout` and no remaining idle time, the executor must -still perform a tightly bounded amount of work before yielding again; otherwise -it can reschedule forever without progress. - -### 3. Keep the public policy seam small - -Preserve `GCInterface` and provider/controller injection. Begin with internal -scan, deletion, and time budgets selected by benchmarks. Add public -`GCOptions` controls only if concrete application needs demonstrate stable, -understandable semantics beyond platform defaults. - -Scheduling strategy, callback handles, continuation state, and platform host -APIs should not become endpoint, schema, or hook configuration. - -### 4. Use platform-specific policy defaults - -Initial target profiles: - -| Concern | Web | React Native | -| --- | --- | --- | -| Scheduling | Browser `requestIdleCallback` with yielding async fallback | React Native `requestIdleCallback` with explicit bounded units | -| Slice size | Conservative | Conservative, validated on mid-range Android | -| Sweep cadence | Current five-minute baseline | More frequent candidate processing | -| Retention | Current baseline | Start unchanged; lower only with refetch and memory evidence | -| Memory pressure | Optional/manual aggressive collection | Research optional application/native bridge | -| Bundle cost | Strictly constrained | Secondary to measured retained-memory improvement | - -An aggressive collection request may shorten retention but must never override -an active reference. Platform profiles should be thin adapters or options, not -LRU logic on every cache access. - -### 5. Do not add default LRU bookkeeping - -LRU or hard entry limits add work to hot reads/writes, increase bundle size, -and do not make eviction itself frame-safe. They also use entry count or recency -as weak proxies for actual memory. Keep such policies optional unless measured -applications demonstrate that age-based cooperative collection is insufficient. - -### 6. Fix correctness and lifecycle gaps - -Before increasing collection frequency: - -- deduplicate entity candidates; -- cancel pending scheduled slices; -- define reacquisition behavior through deletion commit; -- make index cleanup correct and bounded; -- test effect/subscription lifecycle behavior; -- reconcile and document provider defaults. - -## Evaluation of scheduler alternatives - -The cooperative-scheduling proposal contributes several useful conclusions: - -- the scheduling shell and yield boundaries are separate concerns; -- an arbitrary synchronous sweep cannot be made interruptible by wrapping it; -- a queue already provides continuation state; -- cancellation, starvation behavior, and per-slice elapsed-time caps belong in - the scheduler; -- a single step must be bounded; -- an internal executor is preferable to React's unstable scheduler dependency. - -It does not change the overall direction of bounded cooperative GC. It sharpens -the design by favoring a reusable internal scheduling shell instead of embedding -platform timing directly into `runSweep()`. - -Parts that require qualification or do not transfer directly: - -- `requestIdleCallback` is React Native's supported migration path from - `InteractionManager`, but it cannot by itself guarantee frame safety. The - supplied executor is safe only if every `task.step()` is bounded and its - continuation yields as designed. -- Its loop can starve when a timeout fires with no `timeRemaining()`, because it - performs no mandatory bounded step. -- The proposed child-reference decrements and cascading deletion describe graph - GC, not the current consumer-reference model. -- A `CooperativeTask`/`CooperativeScheduler` public interface is unnecessary at - this stage. Keeping it internal avoids premature API and bundle commitments. -- Scheduling one candidate at a time is insufficient if the eventual reducer - still receives and deletes an unbounded batch; scanning and mutation must - both be sliced. - -## Validation gates - -Before changing defaults, establish a measurement protocol and calibrate it -against the current monolithic GC. Exact time, count, cadence, and retention -defaults remain open until variance is measured. The gates below freeze *how* -we measure; they do not invent pass/fail numbers yet. - -### Measurement layers - -Measurement is layered. v1 uses three harnesses with one shared documented JSON -vocabulary and **no** shared runtime package or dependency in this phase: +**Deleting.** The reducer's `case GC` deletes from `entities`, `entitiesMeta`, +`endpoints`, and `meta` **in place** and returns the **same state object**. It +does not touch `indexes` or `optimistic`. + +**Hooks.** Read hooks call `countRef` in `useEffect`, so there is no reference +between render and the passive effect. `useSubscription` alone holds no +reference; `useLive` does, because it also reads. + +**Defaults.** React `DataProvider` uses `GCPolicy`, and `NativeGCPolicy` on +React Native. Vue and a bare `Controller` use `ImmortalGCPolicy` unless one is +passed. `Controller` does not call `gcPolicy.init()`; providers do, via +`initManager`. Public knobs: `intervalMS`, `expiryMultiplier`, a custom +`expiresAt(meta)`, or a whole `GCInterface`. GC options are barely documented. + +**React Native.** `NativeGCPolicy` uses `InteractionManager`, which React +Native 0.86 deprecates: `runAfterInteractions` is `setImmediate`, and +`setDeadline` is a no-op. The supported replacement is `requestIdleCallback`. +It is only a cooperative hint; once a callback starts, React Native cannot +preempt it. + +**React commit timing.** Cache state lives in `useReducer`, through +`useEnhancedReducer` in `DataStore.tsx`. `usePromisifiedDispatch` resolves a +dispatch after its state commits, and `Controller.getState()` returns the last +committed snapshot, so it lags in-flight dispatches. Vue applies the reducer +synchronously inside `realDispatch`, so React's timing gaps do not +automatically apply to Vue. + +## Confirmed gaps + +### Synchronous, unbounded work + +One sweep scans the full queue, and one reducer call deletes the full batch. +Idle scheduling only delays the start; it cannot interrupt the work. A large +queue can therefore drop frames. See [Baselines](#baselines) for sizes. + +### Entities deleted under surviving results + +Test: [`GCPolicy-dangling.ts`](../packages/core/src/state/__tests__/GCPolicy-dangling.ts) + +A result written without a mounted consumer is never queued: `setResponse`, +prefetch, hydrated `initialState`, or a fetch nobody mounted. An entity it +names is still swept when another consumer releases that entity. List schemas +filter missing entities (`filterEmpty` in `schemas/Array.ts`), so the +surviving list silently shrinks. A surviving detail reads `undefined`. Both stay +`Valid`, so nothing refetches. + +Those never-mounted results are also never collected themselves. + +### Deleting a mounted consumer's data + +Test: [`integration-garbage-collection-race.web.tsx`](../packages/react/src/__tests__/integration-garbage-collection-race.web.tsx), +passing on React 17, 18, and 19. + +A sweep that runs after a consumer commits but before its `countRef` effect +sees a zero count and deletes the entity. Because the reducer returns the same +state object, nothing re-renders. The consumer shows stale data until an +unrelated update, then suspends and refetches. In an app this needs an idle +callback to fire between a non-sync commit and React's passive-effect flush. +How often that happens is unmeasured. + +A related hypothesis did **not** reproduce on React 19.2: a GC update queued +ahead of a synchronous mount is applied in the same render, so the new +consumer suspends and refetches instead of losing counted data. A GC +dispatched at transition or idle priority is untested; a pure +`(state, action)` reducer cannot see a count incremented after the action was +queued. + +### Leftover memory after deletion + +- `state.indexes` keeps `indexes[key][indexName][value] = pk` rows. Deleting + one row is cheap from the dying entity's field value (delete only if it + still maps to that pk). Scanning every index is unbounded. +- `normalizr/src/memo/entitiesCache.ts` keeps a strong `Map` of key → pk → + `WeakMap`. Values can be collected by JS GC, but the map entries cannot. + +### Lifecycle + +- `cleanup()` clears the interval but not an already scheduled idle callback. +- There is no memory bound, LRU, or entry limit. JavaScript has no portable + retained-byte API, so a byte-precise policy is not realistic. + +## Prior art + +Checked against official docs (not source), 2026-07-18: + +| Library | What survives | Cost | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| RTK Query | Whole document per endpoint+args; removed after `keepUnusedDataFor` (60s). Never collects an entity out from under a query. | Duplicate copies; tag-driven refetch instead of normalization | +| Relay | Retains queries and mark-sweeps records unreachable from them. A record a retained query references stays. Release buffer: 10 queries. | Needs explicit references and compiler-known selections | +| Apollo `gc()` | Traces from roots and keeps reachable objects. | Tracing cost | +| Apollo `evict(id)` | May leave dangling refs. Lists filter them, like our silent shrink; singular refs need a custom `read`. | Correctness is left to the app | + +## Open decisions + +1. **What is a GC root?** Per-consumer counts allow + [entities deleted under surviving results](#entities-deleted-under-surviving-results). + The alternatives are whole-document eviction (RTK Query, which gives up + normalization), reachability from retained queries (Relay, Apollo `gc`), or + accepting dangling refs (Apollo `evict`, today). +2. **When may GC change a React snapshot?** Immutable deletion committed + alone renders. In-place mutation avoids the render but conflicts with + concurrent reducer replay. Swapping in a prebuilt table on a later real + update avoids an extra render but must stay off that update's critical + path. Never collecting while idle retains memory. React Native may accept a + standalone low-priority commit more readily than web. +3. **How is "still referenced" known at deletion time?** Options: + - read live counts inside the GC reducer (impure, but closes the race); + - count in `useLayoutEffect` instead of `useEffect`; + - commit GC synchronously; + - accept rare deletion of newly mounted data. + + Settle this with a concurrent-rendering test. + +4. **What runs during interaction versus idle?** Unmount must stay cheap. Do + not insert into a heavy structure, walk `Object.keys` of a large store, or + delete inside the reducer on the critical path. One option is a cheap inbox + drained later. +5. **Is the never-mounted leak in scope?** A periodic census, or enqueueing + when data is written, would find never-mounted results. Enqueueing at write + time taxes normalize. +6. **Index rows and memo map entries.** Clean them up as part of whichever + deletion model is chosen, not bolted on first. +7. **Platform policy versus shared mechanism.** One option is shared + correctness with per-platform cadence, retention, and whether idle commits + are allowed. The other is a separate collector per platform. + +## Candidate design: cooperative slicing + +A prior draft. Not chosen. It mainly addresses +[synchronous, unbounded work](#synchronous-unbounded-work), and does not by +itself answer open decisions 1–3. + +JavaScript cannot suspend synchronous work, so frame-safe collection needs +bounded, resumable units and a scheduler that runs them within a budget. + +- **Bound scanning and deletion.** Make queue traversal resumable. Cap both + candidates examined per slice and records deleted per action, by elapsed + time and by count. Each single unit must also be bounded, including index + cleanup. +- **A small internal scheduler, not a dependency.** It schedules slices, + yields on time or count budgets, cancels pending callbacks, and allows + deterministic tests. Avoid React's `scheduler`: it is `unstable_` and ties + core to React. A `requestIdleCallback` loop must still do a small amount of + work when it fires with `didTimeout` and no time left, or it can reschedule + forever. +- **Keep the public surface small.** Keep `GCInterface` and provider + injection. Keep budgets internal until real apps need public knobs. +- **Per-platform defaults.** + + | Concern | Web | React Native | + | ----------- | -------------------------------------------- | --------------------------------------------- | + | Scheduling | `requestIdleCallback` plus an async fallback | `requestIdleCallback` plus bounded units | + | Cadence | Current 5 minutes | More frequent | + | Retention | Current | Unchanged until refetch and memory data exist | + | Bundle cost | Strictly limited | Secondary to memory | + +- **No default LRU.** It taxes every read and write, grows the bundle, and + does not make eviction frame-safe. +- **Prerequisites** before collecting more often: deduplicate entity + candidates, cancel pending slices, define behavior when a candidate is + re-referenced before its deletion commits, bound index cleanup, and document + provider defaults. + +A disposable Node spike compared queue structures at 100k candidates (one +machine, 7 samples): + +| Workload | Flat re-scan | Calendar buckets | +| -------------------------- | -----------: | ---------------: | +| 0% due | ~11.5 ms | ~0.01 ms | +| 10 ticks, mostly unexpired | ~110 ms | ~0.6 ms | +| 100% due | ~7.8 ms | ~25 ms | +| Insert 100k | ~5.9 ms | ~30 ms | + +Buckets win when little is due but cost more at insert time, which happens +during unmount. A min-heap was worse on time and memory. `Object.keys` over +~100k meta took ~4 ms and ~2.7 MiB. Resumable `for...in` slices of 256 took +~0.02 ms. + +## Measuring + +Three opt-in harnesses, not in CI, share one JSON report vocabulary and, via +`examples/gc-shared`, the fixture, scenario axes/IDs, filter syntax, frame +helpers, and summary stats: + +| Harness | Command | Measures | +| ------------------------------------ | -------------------- | ------------------------------------------------------------------- | +| Node, `examples/benchmark` | `start:gc` | Scan, reducer, and end-to-end totals | +| Chromium, `examples/benchmark-react` | `bench:gc` | Input delay, frames, Long Tasks, heap | +| Android, `examples/benchmark-native` | `matrix` / `collect` | Release-Hermes frames and memory over `adb` (never run on a device) | + +Each report is tied to a build manifest of source and artifact hashes; stale +or tampered reports are rejected. Harness-only policies disable the interval +and force expiry to zero so one sweep can be timed. They are fixtures, not +proposed defaults. + +### Protocol + +- **Scenario ID:** `platform` × `candidateKind` (`entity` | `endpoint` | + `mixed`) × `pattern` (`unique` | `duplicate`) × `count` (1k, 10k, 100k) × + `mode` (`scan` | `reducer` | `end-to-end` | `interaction` | `memory`) × + `control` (`gc` | `no-gc`). The 100k run is the main one. Every harness + filters IDs the same way: slash-bounded segments (`/100000/`, + `entity/unique/1000`) or a `^prefix`. +- **Timing window:** build fixtures before timing. Time only the cache-GC + work, let the event loop settle, and force engine GC only for heap + snapshots. Never confuse JS engine GC with cache GC. +- **Reports:** one aggregated JSON per run. Fields include `totalMs`, slice + `max`/`p95`/`p99`, `actionCount`, deletions, and queue size, plus optional + `maxInputDelayMs`, `missedFrames`, `displayPeriodMs`, Long Tasks, and heap. + No per-candidate logging. Today's GC reports one slice and one action; + never fabricate slices. +- **Calibration:** repeat on one host, alternate baseline and candidate + builds, and always run `no-gc` beside `gc`. Judge frames by **excess** over + the control against the measured `displayPeriodMs`, not a hardcoded 16.67 ms. + Freeze thresholds only after variance is known. +- **Judgment order:** interaction and frame impact first; then total time and + action count, which may rise under slicing; then web bundle delta (about + 1 KiB needs a 5–10% measured win); then React Native memory. Foreground + `core` and React benchmarks must stay within noise. +- **Automation:** Jest covers logic only. Node and Chromium could become CI + gates after CI variance runs. Android stays manual. + +### Baselines + +Captured 2026-07-18 on Linux WSL2, Node 24.5, headless Chromium 149 (~16.7 ms +display period), before the harness was committed. Single host, 3–5 samples: +evidence, not thresholds. Raw reports were local and are not in git. + +Node, 100k, median / p95 `totalMs` (5 samples): + +| Scenario | Scan | End-to-end | +| ---------------- | ----------- | ----------- | +| entity unique | 5.2 / 6.6 | 13.0 / 14.5 | +| endpoint unique | 14.7 / 16.6 | 33.7 / 39.1 | +| mixed unique | 9.0 / 9.9 | 21.1 / 21.5 | +| entity duplicate | 2.2 / 2.3 | 5.9 / 6.0 | + +Chromium, 100k `gc`, medians (3 samples): + +| Scenario | `totalMs` | `maxInputDelayMs` | Heap delta | +| ---------------- | --------- | ----------------- | ---------- | +| entity unique | 9.0 | 9.1 | −10.7 MB | +| endpoint unique | 16.7 | 16.8 | −15.0 MB | +| mixed unique | 13.5 | 13.5 | −12.8 MB | +| entity duplicate | 6.8 | 6.8 | −0.4 MB | + +`no-gc` input delay was 0–0.1 ms. Long Tasks stayed at 0 because Chromium only +reports tasks of 50 ms or more. The endpoint sweep still uses about one full +60 Hz frame. A synthetic 45 ms block measured ~45.1 ms max frame interval and +2 missed frames, which confirms the frame probe works. `rdcClient.js` was +33.8 KiB minified (absolute size, not a delta). + +### Rerun + +```bash +# Node +yarn build:benchmark +yarn workspace example-benchmark start:gc /100000/ --samples=5 --no-table + +# Chromium (preview must be serving dist/) +yarn build:benchmark-react +BENCH_GC_OUTPUT=/tmp/gc-browser.json yarn workspace example-benchmark-react \ + bench:gc --samples 3 --scenario 100000 + +# Android (needs a device and a release APK) +yarn workspace example-benchmark-native build:android:release +SAMPLES=5 yarn workspace example-benchmark-native matrix entity/unique/100000 + +# Foreground controls and bundle +yarn workspace example-benchmark start core '^get' +yarn workspace example-benchmark start core '^set' +yarn workspace example-benchmark-react bench:small --lib data-client +yarn ci:build:bundlesize +``` -| Layer | Runtime | Role | -| --- | --- | --- | -| Node | Node.js | Stable, repeatable totals, slice summaries, action/queue cardinality | -| Browser | Chromium | Long tasks, interaction latency, heap snapshots, bundle-adjacent checks | -| Android | Release Hermes | Mid-range device frame stalls and retained memory under navigation/gestures | +## Future work -Each harness may keep a local typed mirror of the result schema. Emitted JSON -must conform **semantically** to the shared vocabulary; cross-harness type -sharing is explicitly out of scope for this phase. - -### Scenario axes - -Every run is identified by a stable, deterministic scenario ID derived from: - -| Axis | Values | -| --- | --- | -| `platform` | `node` \| `browser` \| `android` | -| `candidateKind` | `entity` \| `endpoint` \| `mixed` | -| `pattern` | `unique` \| `duplicate` | -| `count` | Canonical `1000`, `10000`, `100000` | -| `mode` | `scan` \| `reducer` \| `end-to-end` \| `interaction` \| `memory` | -| `control` | `gc` \| `no-gc` | - -IDs must be stable across machines and commits so medians can be compared. The -canonical large gate remains at least 100,000 queued candidates; smaller counts -exist for calibration and CI cost control. - -### Lifecycle boundaries - -Timing windows are explicit. Never conflate V8/Hermes **engine** GC with -data-client **cache** GC. - -1. **Prepare** — build the fixture and queue outside the timed window. -2. **Warmup** — optional; not part of reported totals when used. -3. **Timed cache-GC work** — the measured collection (or no-op control). -4. **Event-loop settling** — allow queued microtasks/macrotasks to drain before - interaction or heap observations that depend on quiescence. -5. **Forced engine GC** — only after interaction timing, and only when taking - heap snapshots. Engine GC pauses are optional metrics, not substitutes for - cache-GC slice timing. -6. **Teardown** — discard fixture state; do not include in samples. - -### Aggregated result vocabulary - -Each run emits one aggregated JSON report. Units are explicit in field names or -documented once per schema version. Do **not** log, mark, or bridge per -candidate; instrumentation cost must not dominate the work being measured. - -Core fields: - -- `schemaVersion` -- commit / build label -- environment fingerprint (runtime, OS, device class as available) -- scenario descriptor (the axes above) -- `samples` and `summary` - -Core metrics: - -- `totalMs` -- optional `sliceDurationsMs`-derived `max` / `p95` / `p99` -- `actionCount` -- targets / deletions -- queue cardinality - -Optional platform metrics (when the harness can collect them): - -- `maxInputDelayMs` -- `missedFrames` -- `displayPeriodMs` -- `longTaskCount` / `longTaskTotalMs` -- `heapBefore` / `heapAfter` / `heapDelta` -- `engineGCPauseMs` -- artifact sizes (for example heap snapshot or trace bytes) - -### Honest representation of monolithic GC - -The current implementation is one unbounded scan plus one deletion action for -eligible candidates. Reports must represent that honestly: - -- normally **one** timed slice and **one** deletion action; -- do **not** fabricate per-candidate or synthetic slice samples. - -When a future cooperative implementation lands, slice boundaries may come from -**benchmark-internal** instrumentation only. This measurement phase does not -add a new public GC API solely to expose slice markers. - -### Fixture setup - -Fixtures are deterministic: - -- fixed seed / data generation; -- `expiresAt` overridden to zero so queued candidates are immediately eligible; -- timerless explicit benchmark policy (no five-minute interval driving the run); -- **unique** pattern: distinct endpoint keys or entity `key`+`pk` pairs; -- **duplicate** pattern: repeat one candidate and report queue entries versus - unique targets. - -Self-validation after each run verifies expected queue cardinality and final -deletion count before the report is accepted. - -### Calibration - -1. Establish the current monolithic baseline **before** cooperative - implementation changes land. -2. Repeat runs on the same machine or device; compare medians for totals and - `p95` / `p99` / `max` for stalls. -3. Alternate baseline and change builds on the same host to reduce drift. -4. Always include a `no-gc` control alongside `gc`. -5. Frame judgment uses measured `displayPeriodMs` and **excess** stalls over the - control — not a hardcoded 16.67 ms assumption. -6. Raw machine reports are **artifacts**, not committed universal baselines. -7. Exact pass thresholds freeze only after measured variance is known. - -### Gate priorities - -Order of judgment when trade-offs appear: - -1. **Interaction / frame impact** is the primary hard gate. Prove that - input/timer callbacks can execute between collection slices; measure browser - long tasks, interaction latency, and React Native JS/UI frame stalls on - mid-range Android during navigation and sustained gestures. -2. **Total collection time** and **action count** may rise under cooperative - slicing within calibrated ceilings. Report worst slice duration, total - duration, and deletion action count. -3. **Web bundle delta** is next (`yarn ci:build:bundlesize`); any increase needs - a measured interaction-latency justification. -4. **React Native retained memory** is next; measure heap during mount/unmount - churn and continuous entity updates where the harness supports it. -5. **Network / refetch impact** from cadence or retention changes is deferred. - This measurement phase does not change those defaults. - -Also required before aggressive default changes: - -- Jest coverage for reacquisition, cancellation, duplicate candidates, index - reuse, and starvation behavior (logic only; see automation boundary). -- Existing core and React benchmark suites remain within noise for foreground - reads and writes. - -### Automation boundary - -| Class | Examples | Automation | -| --- | --- | --- | -| Stable | Node totals; Chromium interaction/long-task runs after calibration | Eligible as CI gates once variance and thresholds exist | -| Expensive / device | Heap snapshots; release-Hermes Android frame and memory | Start as manual; promote only with cost justification | -| Unit tests | Jest correctness for GC policy and reducer behavior | Required for logic; **not** a substitute for idle or frame measurement | - -### Planned commands and artifacts - -Commands and artifact paths are planned, not claimed as already present: - -- Node GC scenarios under the existing benchmark workspace (canonical counts, - `gc` / `no-gc` controls). -- Browser Chromium harness for interaction, long-task, and optional heap modes. -- Android release-Hermes runs on mid-range hardware for frame and retained-memory - modes. -- Aggregated JSON reports plus optional traces/heap snapshots as CI or local - artifacts. -- Calibration notes that record host fingerprint, medians, and variance before - any threshold is frozen. - -Exact script names and directory layout land with the harness implementation. +- **Android on-device calibration.** Not planned soon. Without it there is no + React Native frame or memory baseline, so do not change React Native GC + defaults on Node or Chromium numbers alone. A design can land on web first. + When this is picked up, run the release app on a named mid-range device + (see `examples/benchmark-native/README.md`). +- **Benchmarks in CI.** Only after repeated CI runs show acceptable variance. diff --git a/yarn.lock b/yarn.lock index 90f07d7a42ea..94c8187c37e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1074,7 +1074,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-proposal-export-default-from@npm:^7.29.7": +"@babel/plugin-proposal-export-default-from@npm:^7.24.7, @babel/plugin-proposal-export-default-from@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-proposal-export-default-from@npm:7.29.7" dependencies: @@ -1174,7 +1174,18 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-flow@npm:^7.29.7": +"@babel/plugin-syntax-export-default-from@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/plugin-syntax-export-default-from@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/932c0046ec3035851baa59b7b02493279f2810e8d26a4fb0ee8000334fdab6a4b035567184a7bb31db22f93a2775a01ecde31eaa4bd1b3e5cde85e2562d24b00 + languageName: node + linkType: hard + +"@babel/plugin-syntax-flow@npm:^7.12.1, @babel/plugin-syntax-flow@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-syntax-flow@npm:7.29.7" dependencies: @@ -1384,7 +1395,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-async-generator-functions@npm:^7.29.7": +"@babel/plugin-transform-async-generator-functions@npm:^7.25.4, @babel/plugin-transform-async-generator-functions@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-async-generator-functions@npm:7.29.7" dependencies: @@ -1397,7 +1408,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-async-to-generator@npm:^7.29.7": +"@babel/plugin-transform-async-to-generator@npm:^7.24.7, @babel/plugin-transform-async-to-generator@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-async-to-generator@npm:7.29.7" dependencies: @@ -1421,7 +1432,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-block-scoping@npm:^7.29.7": +"@babel/plugin-transform-block-scoping@npm:^7.25.0, @babel/plugin-transform-block-scoping@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-block-scoping@npm:7.29.7" dependencies: @@ -1432,7 +1443,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-class-properties@npm:^7.24.7, @babel/plugin-transform-class-properties@npm:^7.29.7": +"@babel/plugin-transform-class-properties@npm:^7.24.7, @babel/plugin-transform-class-properties@npm:^7.25.4, @babel/plugin-transform-class-properties@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-class-properties@npm:7.29.7" dependencies: @@ -1456,7 +1467,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-classes@npm:^7.29.7": +"@babel/plugin-transform-classes@npm:^7.25.4, @babel/plugin-transform-classes@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-classes@npm:7.29.7" dependencies: @@ -1484,7 +1495,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-destructuring@npm:^7.29.7": +"@babel/plugin-transform-destructuring@npm:^7.24.8, @babel/plugin-transform-destructuring@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-destructuring@npm:7.29.7" dependencies: @@ -1576,7 +1587,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-flow-strip-types@npm:^7.29.7": +"@babel/plugin-transform-flow-strip-types@npm:^7.25.2, @babel/plugin-transform-flow-strip-types@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-flow-strip-types@npm:7.29.7" dependencies: @@ -1588,7 +1599,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-for-of@npm:^7.29.7": +"@babel/plugin-transform-for-of@npm:^7.24.7, @babel/plugin-transform-for-of@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-for-of@npm:7.29.7" dependencies: @@ -1669,7 +1680,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-modules-commonjs@npm:^7.22.15, @babel/plugin-transform-modules-commonjs@npm:^7.23.3, @babel/plugin-transform-modules-commonjs@npm:^7.24.7, @babel/plugin-transform-modules-commonjs@npm:^7.27.1, @babel/plugin-transform-modules-commonjs@npm:^7.29.7": +"@babel/plugin-transform-modules-commonjs@npm:^7.22.15, @babel/plugin-transform-modules-commonjs@npm:^7.23.3, @babel/plugin-transform-modules-commonjs@npm:^7.24.7, @babel/plugin-transform-modules-commonjs@npm:^7.24.8, @babel/plugin-transform-modules-commonjs@npm:^7.27.1, @babel/plugin-transform-modules-commonjs@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-modules-commonjs@npm:7.29.7" dependencies: @@ -1707,7 +1718,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-named-capturing-groups-regex@npm:^7.29.7": +"@babel/plugin-transform-named-capturing-groups-regex@npm:^7.24.7, @babel/plugin-transform-named-capturing-groups-regex@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-named-capturing-groups-regex@npm:7.29.7" dependencies: @@ -1790,7 +1801,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-optional-catch-binding@npm:^7.29.7": +"@babel/plugin-transform-optional-catch-binding@npm:^7.24.7, @babel/plugin-transform-optional-catch-binding@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-optional-catch-binding@npm:7.29.7" dependencies: @@ -1801,7 +1812,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-optional-chaining@npm:^7.24.7, @babel/plugin-transform-optional-chaining@npm:^7.29.7": +"@babel/plugin-transform-optional-chaining@npm:^7.24.7, @babel/plugin-transform-optional-chaining@npm:^7.24.8, @babel/plugin-transform-optional-chaining@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-optional-chaining@npm:7.29.7" dependencies: @@ -1836,7 +1847,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-private-property-in-object@npm:^7.29.7": +"@babel/plugin-transform-private-property-in-object@npm:^7.24.7, @babel/plugin-transform-private-property-in-object@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-private-property-in-object@npm:7.29.7" dependencies: @@ -1871,7 +1882,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-react-display-name@npm:^7.29.7": +"@babel/plugin-transform-react-display-name@npm:^7.24.7, @babel/plugin-transform-react-display-name@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-react-display-name@npm:7.29.7" dependencies: @@ -1905,7 +1916,29 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-react-jsx@npm:^7.29.7": +"@babel/plugin-transform-react-jsx-self@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-react-jsx-self@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/288995f0fd0d61ab740a315fb56c8255eb87dd4a4ac2ac7d0fdd4ce173c3878200141e80da2db0e598c7b2a71e74e604afdbb4c8e14ae6e0527ce0b6294c03da + languageName: node + linkType: hard + +"@babel/plugin-transform-react-jsx-source@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-react-jsx-source@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/a121899631e6d99b9e1b276acf736dbb77948a31f8eeeae67b89c8a4ab0f05e51ba64544baa06c286a2b9944f227244e15aac464e2313d286d0511fe51e27975 + languageName: node + linkType: hard + +"@babel/plugin-transform-react-jsx@npm:^7.25.2, @babel/plugin-transform-react-jsx@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-react-jsx@npm:7.29.7" dependencies: @@ -1932,6 +1965,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-regenerator@npm:^7.24.7": + version: 7.29.8 + resolution: "@babel/plugin-transform-regenerator@npm:7.29.8" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/768da9bc3c8cbb0c591bc7db050215b8cea44df9df525d5cd6034aa179b091c2321a745b68139b3fc70b4493f2df1fc99a57acd4029c6d355d32a8ed8bd69f82 + languageName: node + linkType: hard + "@babel/plugin-transform-regenerator@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-regenerator@npm:7.29.7" @@ -1966,7 +2010,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-runtime@npm:^7.22.15, @babel/plugin-transform-runtime@npm:^7.25.9, @babel/plugin-transform-runtime@npm:^7.29.7": +"@babel/plugin-transform-runtime@npm:^7.22.15, @babel/plugin-transform-runtime@npm:^7.24.7, @babel/plugin-transform-runtime@npm:^7.25.9, @babel/plugin-transform-runtime@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-runtime@npm:7.29.7" dependencies: @@ -2038,6 +2082,21 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-typescript@npm:^7.25.2": + version: 7.29.9 + resolution: "@babel/plugin-transform-typescript@npm:7.29.9" + dependencies: + "@babel/helper-annotate-as-pure": "npm:^7.29.7" + "@babel/helper-create-class-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7" + "@babel/plugin-syntax-typescript": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/6caf62e8359fa08b112d7762036cd3c82ec5c5fd604f5faf4446a7f7b8fa8a85e5536e8425dc61dc3846316b5fdc9adb59de0cc51e39d3354a89bb31705f36de + languageName: node + linkType: hard + "@babel/plugin-transform-typescript@npm:^7.28.5, @babel/plugin-transform-typescript@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-typescript@npm:7.29.7" @@ -2076,7 +2135,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-unicode-regex@npm:^7.29.7": +"@babel/plugin-transform-unicode-regex@npm:^7.24.7, @babel/plugin-transform-unicode-regex@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-unicode-regex@npm:7.29.7" dependencies: @@ -2100,7 +2159,7 @@ __metadata: languageName: node linkType: hard -"@babel/preset-env@npm:^7.20.2, @babel/preset-env@npm:^7.22.15, @babel/preset-env@npm:^7.25.9, @babel/preset-env@npm:^7.29.7": +"@babel/preset-env@npm:^7.20.2, @babel/preset-env@npm:^7.22.15, @babel/preset-env@npm:^7.25.3, @babel/preset-env@npm:^7.25.9, @babel/preset-env@npm:^7.29.7": version: 7.29.7 resolution: "@babel/preset-env@npm:7.29.7" dependencies: @@ -6323,6 +6382,184 @@ __metadata: languageName: node linkType: hard +"@react-native-community/cli-clean@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-clean@npm:20.1.0" + dependencies: + "@react-native-community/cli-tools": "npm:20.1.0" + execa: "npm:^5.0.0" + fast-glob: "npm:^3.3.2" + picocolors: "npm:^1.1.1" + checksum: 10c0/5d7fce73545154035e55801e9596b634c85bbe7bb04861f3d0787f2a9588b7b6f927709bfce4e45a8d62da03e5a92789281e07dafd29e2db86ca52ca3e5ad49c + languageName: node + linkType: hard + +"@react-native-community/cli-config-android@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-config-android@npm:20.1.0" + dependencies: + "@react-native-community/cli-tools": "npm:20.1.0" + fast-glob: "npm:^3.3.2" + fast-xml-parser: "npm:^4.4.1" + picocolors: "npm:^1.1.1" + checksum: 10c0/66e95adf2fb9750a12592a146b034ab4b8ea15aa41efb5d497d6be2f08029a59a0be2450e97de0bf59aa6b66778b3af0bbd693a42b7e7f9f60c9d9b5f966aadc + languageName: node + linkType: hard + +"@react-native-community/cli-config-apple@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-config-apple@npm:20.1.0" + dependencies: + "@react-native-community/cli-tools": "npm:20.1.0" + execa: "npm:^5.0.0" + fast-glob: "npm:^3.3.2" + picocolors: "npm:^1.1.1" + checksum: 10c0/2c7b7116594c7b0035ef7e41e0219fcff664c3883471460ae48f19dd88ec9a26dd5b4bd60e33c054603b3a10efd78570490bc84599ad987e4ffb274b05512351 + languageName: node + linkType: hard + +"@react-native-community/cli-config@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-config@npm:20.1.0" + dependencies: + "@react-native-community/cli-tools": "npm:20.1.0" + cosmiconfig: "npm:^9.0.0" + deepmerge: "npm:^4.3.0" + fast-glob: "npm:^3.3.2" + joi: "npm:^17.2.1" + picocolors: "npm:^1.1.1" + checksum: 10c0/e3d863ffc5d18aa7b7406908c1360de7ea7a1bab32897e34d0afe184f4682bb5d110d4db0c6ba2c35c4f1330ca4a281b8fc3548e53a2978b8b07ca3beca5765c + languageName: node + linkType: hard + +"@react-native-community/cli-doctor@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-doctor@npm:20.1.0" + dependencies: + "@react-native-community/cli-config": "npm:20.1.0" + "@react-native-community/cli-platform-android": "npm:20.1.0" + "@react-native-community/cli-platform-apple": "npm:20.1.0" + "@react-native-community/cli-platform-ios": "npm:20.1.0" + "@react-native-community/cli-tools": "npm:20.1.0" + command-exists: "npm:^1.2.8" + deepmerge: "npm:^4.3.0" + envinfo: "npm:^7.13.0" + execa: "npm:^5.0.0" + node-stream-zip: "npm:^1.9.1" + ora: "npm:^5.4.1" + picocolors: "npm:^1.1.1" + semver: "npm:^7.5.2" + wcwidth: "npm:^1.0.1" + yaml: "npm:^2.2.1" + checksum: 10c0/79762f3e789fe98f21c6f500f6c65df399508979f05174272652a9422295f4dace099e87575776bf209b533a6667070fc355c5b510e14a8a61b31a9a8af7b3be + languageName: node + linkType: hard + +"@react-native-community/cli-platform-android@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-platform-android@npm:20.1.0" + dependencies: + "@react-native-community/cli-config-android": "npm:20.1.0" + "@react-native-community/cli-tools": "npm:20.1.0" + execa: "npm:^5.0.0" + logkitty: "npm:^0.7.1" + picocolors: "npm:^1.1.1" + checksum: 10c0/8b44178a78245085824c3fda9d6753d03cd817772fdde82b1d4f1a89f177f10339822401c0b8ef17e8353f3168c3971ae4cfb0a158cef03c22e55f5dff66179b + languageName: node + linkType: hard + +"@react-native-community/cli-platform-apple@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-platform-apple@npm:20.1.0" + dependencies: + "@react-native-community/cli-config-apple": "npm:20.1.0" + "@react-native-community/cli-tools": "npm:20.1.0" + execa: "npm:^5.0.0" + fast-xml-parser: "npm:^4.4.1" + picocolors: "npm:^1.1.1" + checksum: 10c0/2218e172bf13c0bb027f7f2a036a0515f8fe023d98fc65d22d0dab601036c5b461b1af7a5d826df2dd3e86a3894f9531d43e8861746a071ee22c0b33b8e47c29 + languageName: node + linkType: hard + +"@react-native-community/cli-platform-ios@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-platform-ios@npm:20.1.0" + dependencies: + "@react-native-community/cli-platform-apple": "npm:20.1.0" + checksum: 10c0/f5eef9dfbe3dad7dc69f4b3a37ebee9d124f3a649071e0275624f88468a7f506b622646dcbd61ba0435cc35e705badfc287690d6b7c3867c0a86c9a45d4f463e + languageName: node + linkType: hard + +"@react-native-community/cli-server-api@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-server-api@npm:20.1.0" + dependencies: + "@react-native-community/cli-tools": "npm:20.1.0" + body-parser: "npm:^1.20.3" + compression: "npm:^1.7.1" + connect: "npm:^3.6.5" + errorhandler: "npm:^1.5.1" + nocache: "npm:^3.0.1" + open: "npm:^6.2.0" + pretty-format: "npm:^29.7.0" + serve-static: "npm:^1.13.1" + ws: "npm:^6.2.3" + checksum: 10c0/d83c9bbff36fb84201478ed8efc17c80f1474cb0346a2577eee7299407e9e34892c213e2316062a2c59d47ebfe9f0c8215b82cd1abb05c85fe7702a94a52cdf5 + languageName: node + linkType: hard + +"@react-native-community/cli-tools@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-tools@npm:20.1.0" + dependencies: + "@vscode/sudo-prompt": "npm:^9.0.0" + appdirsjs: "npm:^1.2.4" + execa: "npm:^5.0.0" + find-up: "npm:^5.0.0" + launch-editor: "npm:^2.9.1" + mime: "npm:^2.4.1" + ora: "npm:^5.4.1" + picocolors: "npm:^1.1.1" + prompts: "npm:^2.4.2" + semver: "npm:^7.5.2" + checksum: 10c0/993f5dfbc1ae6301e616269896b2e5ecc29888cc8a12325881148b546b07d45572bd065e392ceaad32f86067e31b0eabedbe71fac3b1b820cd489ab86e56094d + languageName: node + linkType: hard + +"@react-native-community/cli-types@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-types@npm:20.1.0" + dependencies: + joi: "npm:^17.2.1" + checksum: 10c0/247deebf26b435c0cbabfb7dd7de09a4e79c544ee72a2ef2817268108f7c085dab4d54c7fbc8111ad89360e13227f2041c5eac208971c251da332e4d33311a7b + languageName: node + linkType: hard + +"@react-native-community/cli@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli@npm:20.1.0" + dependencies: + "@react-native-community/cli-clean": "npm:20.1.0" + "@react-native-community/cli-config": "npm:20.1.0" + "@react-native-community/cli-doctor": "npm:20.1.0" + "@react-native-community/cli-server-api": "npm:20.1.0" + "@react-native-community/cli-tools": "npm:20.1.0" + "@react-native-community/cli-types": "npm:20.1.0" + commander: "npm:^9.4.1" + deepmerge: "npm:^4.3.0" + execa: "npm:^5.0.0" + find-up: "npm:^5.0.0" + fs-extra: "npm:^8.1.0" + graceful-fs: "npm:^4.1.3" + picocolors: "npm:^1.1.1" + prompts: "npm:^2.4.2" + semver: "npm:^7.5.2" + bin: + rnc-cli: build/bin.js + checksum: 10c0/f3fa43fc8c170ba291ae81e796f4ac35956e143bf1094f15f91061b93835ed0665749ca11da91df7fbfcdd96ee32fc16da6cab60adef1a21022195aa1947bd5d + languageName: node + linkType: hard + "@react-native/assets-registry@npm:0.86.2": version: 0.86.2 resolution: "@react-native/assets-registry@npm:0.86.2" @@ -6330,6 +6567,59 @@ __metadata: languageName: node linkType: hard +"@react-native/babel-plugin-codegen@npm:0.86.2": + version: 0.86.2 + resolution: "@react-native/babel-plugin-codegen@npm:0.86.2" + dependencies: + "@babel/traverse": "npm:^7.29.0" + "@react-native/codegen": "npm:0.86.2" + checksum: 10c0/ea2faab711f07578f0d0b1a62ccc731a60fdb21b0bf3924209fa055d62f597d6104f6646b5cbf416da4dc1a012f5c708e7d58694ddfafab9325dd60b0bf495f2 + languageName: node + linkType: hard + +"@react-native/babel-preset@npm:0.86.2": + version: 0.86.2 + resolution: "@react-native/babel-preset@npm:0.86.2" + dependencies: + "@babel/core": "npm:^7.25.2" + "@babel/plugin-proposal-export-default-from": "npm:^7.24.7" + "@babel/plugin-syntax-dynamic-import": "npm:^7.8.3" + "@babel/plugin-syntax-export-default-from": "npm:^7.24.7" + "@babel/plugin-syntax-nullish-coalescing-operator": "npm:^7.8.3" + "@babel/plugin-syntax-optional-chaining": "npm:^7.8.3" + "@babel/plugin-transform-async-generator-functions": "npm:^7.25.4" + "@babel/plugin-transform-async-to-generator": "npm:^7.24.7" + "@babel/plugin-transform-block-scoping": "npm:^7.25.0" + "@babel/plugin-transform-class-properties": "npm:^7.25.4" + "@babel/plugin-transform-classes": "npm:^7.25.4" + "@babel/plugin-transform-destructuring": "npm:^7.24.8" + "@babel/plugin-transform-flow-strip-types": "npm:^7.25.2" + "@babel/plugin-transform-for-of": "npm:^7.24.7" + "@babel/plugin-transform-modules-commonjs": "npm:^7.24.8" + "@babel/plugin-transform-named-capturing-groups-regex": "npm:^7.24.7" + "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.24.7" + "@babel/plugin-transform-optional-catch-binding": "npm:^7.24.7" + "@babel/plugin-transform-optional-chaining": "npm:^7.24.8" + "@babel/plugin-transform-private-methods": "npm:^7.24.7" + "@babel/plugin-transform-private-property-in-object": "npm:^7.24.7" + "@babel/plugin-transform-react-display-name": "npm:^7.24.7" + "@babel/plugin-transform-react-jsx": "npm:^7.25.2" + "@babel/plugin-transform-react-jsx-self": "npm:^7.24.7" + "@babel/plugin-transform-react-jsx-source": "npm:^7.24.7" + "@babel/plugin-transform-regenerator": "npm:^7.24.7" + "@babel/plugin-transform-runtime": "npm:^7.24.7" + "@babel/plugin-transform-typescript": "npm:^7.25.2" + "@babel/plugin-transform-unicode-regex": "npm:^7.24.7" + "@react-native/babel-plugin-codegen": "npm:0.86.2" + babel-plugin-syntax-hermes-parser: "npm:0.36.0" + babel-plugin-transform-flow-enums: "npm:^0.0.2" + react-refresh: "npm:^0.14.0" + peerDependencies: + "@babel/core": "*" + checksum: 10c0/12358b3898771cf7340c3fa0199e3608b9aca03fd33d6f10af59d0acef65181d9893acab51c068adc816231df9e5baff360b042963e633fc5f24a73faf6387b2 + languageName: node + linkType: hard + "@react-native/codegen@npm:0.86.2": version: 0.86.2 resolution: "@react-native/codegen@npm:0.86.2" @@ -6437,6 +6727,32 @@ __metadata: languageName: node linkType: hard +"@react-native/metro-babel-transformer@npm:0.86.2": + version: 0.86.2 + resolution: "@react-native/metro-babel-transformer@npm:0.86.2" + dependencies: + "@babel/core": "npm:^7.25.2" + "@react-native/babel-preset": "npm:0.86.2" + hermes-parser: "npm:0.36.0" + nullthrows: "npm:^1.1.1" + peerDependencies: + "@babel/core": "*" + checksum: 10c0/c4841accee16c5cf0054462bc6d11d94cb327192e11934b153f28feb980ebe2079167058805621593b0dd61f0fe8f17cac3340db4b8644797a15893210c0bf7d + languageName: node + linkType: hard + +"@react-native/metro-config@npm:0.86.2": + version: 0.86.2 + resolution: "@react-native/metro-config@npm:0.86.2" + dependencies: + "@react-native/js-polyfills": "npm:0.86.2" + "@react-native/metro-babel-transformer": "npm:0.86.2" + metro-config: "npm:^0.84.3" + metro-runtime: "npm:^0.84.3" + checksum: 10c0/5e7408301b9a06f9da34598d0fd355ef5bf610721113c3e3d7ee8ff2a9a92ce4218bcda8302b7d3d722e1ae6f4e1db906703fb8ce08466b4b89bdd2fdd4fc111 + languageName: node + linkType: hard + "@react-native/normalize-colors@npm:0.86.2": version: 0.86.2 resolution: "@react-native/normalize-colors@npm:0.86.2" @@ -6444,6 +6760,13 @@ __metadata: languageName: node linkType: hard +"@react-native/typescript-config@npm:0.86.2": + version: 0.86.2 + resolution: "@react-native/typescript-config@npm:0.86.2" + checksum: 10c0/473908af62140c9fecb13ea41a6d3f9ae4296cf6d4f481964aebac56f484b56500e07462e2940585001d4dd0fadb102d4c4de871d57e8b981ffffc2d4a83fddb + languageName: node + linkType: hard + "@react-native/virtualized-lists@npm:0.86.2": version: 0.86.2 resolution: "@react-native/virtualized-lists@npm:0.86.2" @@ -8109,6 +8432,15 @@ __metadata: languageName: node linkType: hard +"@types/react-test-renderer@npm:^19.1.0": + version: 19.3.0 + resolution: "@types/react-test-renderer@npm:19.3.0" + dependencies: + "@types/react": "npm:*" + checksum: 10c0/37f5588f698a057a33c1f4d5fd28a042f3df7f29a3554ddebb7f9c8ecd3e5fba085e5b12d14b30734c30b51959ef17544650b2a9704d629d0af251f126d8ae73 + languageName: node + linkType: hard + "@types/react@npm:19.2.17": version: 19.2.17 resolution: "@types/react@npm:19.2.17" @@ -8118,6 +8450,15 @@ __metadata: languageName: node linkType: hard +"@types/react@npm:^19.2.0": + version: 19.3.0 + resolution: "@types/react@npm:19.3.0" + dependencies: + csstype: "npm:^3.2.2" + checksum: 10c0/fbcabf303f935ca9f36ed2dcf81751cf84744098c5a683c8a62a0fa7edd31714b48d5c46b12fd0014f7f2dfe4d9ec6de428a850748eec719a70294e51029551b + languageName: node + linkType: hard + "@types/resolve@npm:1.20.2": version: 1.20.2 resolution: "@types/resolve@npm:1.20.2" @@ -8870,6 +9211,13 @@ __metadata: languageName: node linkType: hard +"@vscode/sudo-prompt@npm:^9.0.0": + version: 9.3.2 + resolution: "@vscode/sudo-prompt@npm:9.3.2" + checksum: 10c0/9cf63f7001f31ada248aefe0d289e8769d82d9eeb12845aef863faf44620cbe620897625af4e160ab1c2a684d88247a0dbaead0d9a9447a5807feb4a4fd47016 + languageName: node + linkType: hard + "@vue/compiler-core@npm:3.5.40": version: 3.5.40 resolution: "@vue/compiler-core@npm:3.5.40" @@ -9582,6 +9930,17 @@ __metadata: languageName: node linkType: hard +"ansi-fragments@npm:^0.2.1": + version: 0.2.1 + resolution: "ansi-fragments@npm:0.2.1" + dependencies: + colorette: "npm:^1.0.7" + slice-ansi: "npm:^2.0.0" + strip-ansi: "npm:^5.0.0" + checksum: 10c0/44e97e558ca2f0b2ca895bfd6ebebeb2e77d674d2e4198ac2d3a05b690193fa35fd185db6e16b92dd0ee854299ea8b4387a99e4155ea62bc8ad4c42154542fd4 + languageName: node + linkType: hard + "ansi-html-community@npm:^0.0.8": version: 0.0.8 resolution: "ansi-html-community@npm:0.0.8" @@ -9591,6 +9950,13 @@ __metadata: languageName: node linkType: hard +"ansi-regex@npm:^4.1.0": + version: 4.1.1 + resolution: "ansi-regex@npm:4.1.1" + checksum: 10c0/d36d34234d077e8770169d980fed7b2f3724bfa2a01da150ccd75ef9707c80e883d27cdf7a0eac2f145ac1d10a785a8a855cffd05b85f778629a0db62e7033da + languageName: node + linkType: hard + "ansi-regex@npm:^5.0.0, ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -9605,7 +9971,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^3.2.1": +"ansi-styles@npm:^3.2.0, ansi-styles@npm:^3.2.1": version: 3.2.1 resolution: "ansi-styles@npm:3.2.1" dependencies: @@ -9676,6 +10042,13 @@ __metadata: languageName: node linkType: hard +"appdirsjs@npm:^1.2.4": + version: 1.2.8 + resolution: "appdirsjs@npm:1.2.8" + checksum: 10c0/dc16a9aa5e9fd2fc8c7a0daaa7184b74c6e4e52afe9242ae73b683af16468d55eb9325b03926721c77307de668696a2e2e935253d107babe8daed96f822d849b + languageName: node + linkType: hard + "arch@npm:^2.2.0": version: 2.2.0 resolution: "arch@npm:2.2.0" @@ -9931,6 +10304,13 @@ __metadata: languageName: node linkType: hard +"astral-regex@npm:^1.0.0": + version: 1.0.0 + resolution: "astral-regex@npm:1.0.0" + checksum: 10c0/ca460207a19d84c65671e1a85940101522d42f31a450cdb8f93b3464e6daeaf4b58a362826a6c11c57e6cd1976403d197abb0447cfc2087993a29b35c6d63b63 + languageName: node + linkType: hard + "astring@npm:^1.8.0": version: 1.8.6 resolution: "astring@npm:1.8.6" @@ -9940,6 +10320,13 @@ __metadata: languageName: node linkType: hard +"async-limiter@npm:~1.0.0": + version: 1.0.1 + resolution: "async-limiter@npm:1.0.1" + checksum: 10c0/0693d378cfe86842a70d4c849595a0bb50dc44c11649640ca982fa90cbfc74e3cc4753b5a0847e51933f2e9c65ce8e05576e75e5e1fd963a086e673735b35969 + languageName: node + linkType: hard + "async@npm:^3.2.3, async@npm:^3.2.6": version: 3.2.6 resolution: "async@npm:3.2.6" @@ -10235,6 +10622,15 @@ __metadata: languageName: node linkType: hard +"babel-plugin-transform-flow-enums@npm:^0.0.2": + version: 0.0.2 + resolution: "babel-plugin-transform-flow-enums@npm:0.0.2" + dependencies: + "@babel/plugin-syntax-flow": "npm:^7.12.1" + checksum: 10c0/aa9d022d8d4be0e7c4f1ff7e5308fe7e0ff4d6f9099449913e3a11c1e81916623a8f36432da180a9aa3f53ea534dca4401fe33d6528f043f40357cfa790ee778 + languageName: node + linkType: hard + "babel-plugin-transform-import-meta@npm:^2.3.3": version: 2.3.3 resolution: "babel-plugin-transform-import-meta@npm:2.3.3" @@ -10426,6 +10822,17 @@ __metadata: languageName: node linkType: hard +"bl@npm:^4.1.0": + version: 4.1.0 + resolution: "bl@npm:4.1.0" + dependencies: + buffer: "npm:^5.5.0" + inherits: "npm:^2.0.4" + readable-stream: "npm:^3.4.0" + checksum: 10c0/02847e1d2cb089c9dc6958add42e3cdeaf07d13f575973963335ac0fdece563a50ac770ac4c8fa06492d2dd276f6cc3b7f08c7cd9c7a7ad0f8d388b2a28def5f + languageName: node + linkType: hard + "bl@npm:^5.0.0": version: 5.1.0 resolution: "bl@npm:5.1.0" @@ -10451,6 +10858,26 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:^1.20.3": + version: 1.20.8 + resolution: "body-parser@npm:1.20.8" + dependencies: + bytes: "npm:~3.1.2" + content-type: "npm:~1.0.5" + debug: "npm:2.6.9" + depd: "npm:2.0.0" + destroy: "npm:~1.2.0" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.4.24" + on-finished: "npm:~2.4.1" + qs: "npm:~6.16.0" + raw-body: "npm:~2.5.3" + type-is: "npm:~1.6.18" + unpipe: "npm:~1.0.0" + checksum: 10c0/7edc8c4762d52ff685af081b1d5ccad860edcec662c25893be452b16042fd55b9c9869a194bd282e0ad936c34eb07866b2b33f0cc02ff916597058fd229c13d2 + languageName: node + linkType: hard + "body-parser@npm:~1.20.5": version: 1.20.6 resolution: "body-parser@npm:1.20.6" @@ -10726,6 +11153,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^5.5.0": + version: 5.7.1 + resolution: "buffer@npm:5.7.1" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.1.13" + checksum: 10c0/27cac81cff434ed2876058d72e7c4789d11ff1120ef32c9de48f59eab58179b66710c488987d295ae89a228f835fc66d088652dffeb8e3ba8659f80eb091d55e + languageName: node + linkType: hard + "buffer@npm:^6.0.3": version: 6.0.3 resolution: "buffer@npm:6.0.3" @@ -10881,7 +11318,7 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^5.3.1": +"camelcase@npm:^5.0.0, camelcase@npm:^5.3.1": version: 5.3.1 resolution: "camelcase@npm:5.3.1" checksum: 10c0/92ff9b443bfe8abb15f2b1513ca182d16126359ad4f955ebc83dc4ddcc4ef3fdd2c078bc223f2673dc223488e75c99b16cc4d056624374b799e6a1555cf61b23 @@ -11205,6 +11642,15 @@ __metadata: languageName: node linkType: hard +"cli-cursor@npm:^3.1.0": + version: 3.1.0 + resolution: "cli-cursor@npm:3.1.0" + dependencies: + restore-cursor: "npm:^3.1.0" + checksum: 10c0/92a2f98ff9037d09be3dfe1f0d749664797fb674bf388375a2207a1203b69d41847abf16434203e0089212479e47a358b13a0222ab9fccfe8e2644a7ccebd111 + languageName: node + linkType: hard + "cli-cursor@npm:^4.0.0": version: 4.0.0 resolution: "cli-cursor@npm:4.0.0" @@ -11214,7 +11660,7 @@ __metadata: languageName: node linkType: hard -"cli-spinners@npm:^2.6.1": +"cli-spinners@npm:^2.5.0, cli-spinners@npm:^2.6.1": version: 2.9.2 resolution: "cli-spinners@npm:2.9.2" checksum: 10c0/907a1c227ddf0d7a101e7ab8b300affc742ead4b4ebe920a5bf1bc6d45dce2958fcd195eb28fa25275062fe6fa9b109b93b63bc8033396ed3bcb50297008b3a3 @@ -11252,6 +11698,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^6.0.0": + version: 6.0.0 + resolution: "cliui@npm:6.0.0" + dependencies: + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.0" + wrap-ansi: "npm:^6.2.0" + checksum: 10c0/35229b1bb48647e882104cac374c9a18e34bbf0bace0e2cf03000326b6ca3050d6b59545d91e17bfe3705f4a0e2988787aa5cde6331bf5cbbf0164732cef6492 + languageName: node + linkType: hard + "cliui@npm:^7.0.2": version: 7.0.4 resolution: "cliui@npm:7.0.4" @@ -11420,6 +11877,13 @@ __metadata: languageName: node linkType: hard +"colorette@npm:^1.0.7": + version: 1.4.0 + resolution: "colorette@npm:1.4.0" + checksum: 10c0/4955c8f7daafca8ae7081d672e4bd89d553bd5782b5846d5a7e05effe93c2f15f7e9c0cb46f341b59f579a39fcf436241ff79594899d75d5f3460c03d607fe9e + languageName: node + linkType: hard + "colorette@npm:^2.0.10, colorette@npm:^2.0.14": version: 2.0.20 resolution: "colorette@npm:2.0.20" @@ -11448,6 +11912,13 @@ __metadata: languageName: node linkType: hard +"command-exists@npm:^1.2.8": + version: 1.2.9 + resolution: "command-exists@npm:1.2.9" + checksum: 10c0/75040240062de46cd6cd43e6b3032a8b0494525c89d3962e280dde665103f8cc304a8b313a5aa541b91da2f5a9af75c5959dc3a77893a2726407a5e9a0234c16 + languageName: node + linkType: hard + "commander@npm:7, commander@npm:^7.2.0": version: 7.2.0 resolution: "commander@npm:7.2.0" @@ -11518,6 +11989,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^9.4.1": + version: 9.5.0 + resolution: "commander@npm:9.5.0" + checksum: 10c0/5f7784fbda2aaec39e89eb46f06a999e00224b3763dc65976e05929ec486e174fe9aac2655f03ba6a5e83875bd173be5283dc19309b7c65954701c02025b3c1d + languageName: node + linkType: hard + "common-path-prefix@npm:^3.0.0": version: 3.0.0 resolution: "common-path-prefix@npm:3.0.0" @@ -11556,6 +12034,22 @@ __metadata: languageName: node linkType: hard +"compression@npm:^1.7.1": + version: 1.8.2 + resolution: "compression@npm:1.8.2" + dependencies: + bytes: "npm:3.1.2" + compressible: "npm:~2.0.18" + debug: "npm:2.6.9" + destroy: "npm:1.2.0" + negotiator: "npm:~0.6.4" + on-headers: "npm:~1.1.0" + safe-buffer: "npm:5.2.1" + vary: "npm:~1.1.2" + checksum: 10c0/f95be7122323fdffcaadc807f42da38b5227eeac7d9997c1c4314abd7ca4a4de2daa3d5bd4e142706bef4d34fa0f64cd265c6f074b221143e18da1215c0a07ab + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -12828,6 +13322,13 @@ __metadata: languageName: node linkType: hard +"dayjs@npm:^1.8.15": + version: 1.11.23 + resolution: "dayjs@npm:1.11.23" + checksum: 10c0/69ab04bf19c676e44ab50cc2fca223d265f3dafd107151ef3b0f3ad74d360c37caa602abe62f87cb25d90bd9f6bee003698af47df5b0dbf10a998b7b5f3bb3f1 + languageName: node + linkType: hard + "debounce@npm:^1.2.1": version: 1.2.1 resolution: "debounce@npm:1.2.1" @@ -12865,6 +13366,13 @@ __metadata: languageName: node linkType: hard +"decamelize@npm:^1.2.0": + version: 1.2.0 + resolution: "decamelize@npm:1.2.0" + checksum: 10c0/85c39fe8fbf0482d4a1e224ef0119db5c1897f8503bcef8b826adff7a1b11414972f6fef2d7dec2ee0b4be3863cf64ac1439137ae9e6af23a3d8dcbe26a5b4b2 + languageName: node + linkType: hard + "decimal.js@npm:^10.5.0": version: 10.5.0 resolution: "decimal.js@npm:10.5.0" @@ -12930,7 +13438,7 @@ __metadata: languageName: node linkType: hard -"deepmerge@npm:^4.2.2, deepmerge@npm:^4.3.1": +"deepmerge@npm:^4.2.2, deepmerge@npm:^4.3.0, deepmerge@npm:^4.3.1": version: 4.3.1 resolution: "deepmerge@npm:4.3.1" checksum: 10c0/e53481aaf1aa2c4082b5342be6b6d8ad9dfe387bc92ce197a66dea08bd4265904a087e75e464f14d1347cf2ac8afe1e4c16b266e0561cc5df29382d3c5f80044 @@ -13569,6 +14077,15 @@ __metadata: languageName: node linkType: hard +"envinfo@npm:^7.13.0": + version: 7.21.0 + resolution: "envinfo@npm:7.21.0" + bin: + envinfo: dist/cli.js + checksum: 10c0/4170127ca72dbf85be2c114f85558bd08178e8a43b394951ba9fd72d067c6fea3374df45a7b040e39e4e7b30bdd268e5bdf8661d99ae28302c2a88dedb41b5e6 + languageName: node + linkType: hard + "envinfo@npm:^7.14.0": version: 7.14.0 resolution: "envinfo@npm:7.14.0" @@ -13603,6 +14120,16 @@ __metadata: languageName: node linkType: hard +"errorhandler@npm:^1.5.1": + version: 1.5.2 + resolution: "errorhandler@npm:1.5.2" + dependencies: + accepts: "npm:~1.3.8" + escape-html: "npm:~1.0.3" + checksum: 10c0/13fc3ba2358893f1f2da43e246105d42a78bf448bf55257b75114c757bd566dcae8b0cd76a3c8777bc451a552a9215979a5e8205bdeee066550cc4acabbfd5af + languageName: node + linkType: hard + "es-abstract@npm:^1.17.0-next.1, es-abstract@npm:^1.17.5, es-abstract@npm:^1.23.2, es-abstract@npm:^1.23.3, es-abstract@npm:^1.23.5, es-abstract@npm:^1.23.6, es-abstract@npm:^1.23.9, es-abstract@npm:^1.24.0, es-abstract@npm:^1.5.1": version: 1.24.0 resolution: "es-abstract@npm:1.24.0" @@ -14407,6 +14934,31 @@ __metadata: languageName: node linkType: hard +"example-benchmark-native@workspace:examples/benchmark-native": + version: 0.0.0-use.local + resolution: "example-benchmark-native@workspace:examples/benchmark-native" + dependencies: + "@babel/core": "npm:^7.25.2" + "@babel/preset-env": "npm:^7.25.3" + "@babel/runtime": "npm:^7.25.0" + "@data-client/core": "workspace:*" + "@react-native-community/cli": "npm:20.1.0" + "@react-native-community/cli-platform-android": "npm:20.1.0" + "@react-native/babel-preset": "npm:0.86.2" + "@react-native/metro-config": "npm:0.86.2" + "@react-native/typescript-config": "npm:0.86.2" + "@types/jest": "npm:30.0.0" + "@types/react": "npm:^19.2.0" + "@types/react-test-renderer": "npm:^19.1.0" + "@typescript/native": "npm:typescript@7.0.2" + jest: "npm:^30.0.0" + react: "npm:19.2.3" + react-native: "npm:0.86.2" + react-test-renderer: "npm:19.2.3" + typescript: "npm:@typescript/typescript6@6.0.2" + languageName: unknown + linkType: soft + "example-benchmark-react@workspace:examples/benchmark-react": version: 0.0.0-use.local resolution: "example-benchmark-react@workspace:examples/benchmark-react" @@ -14458,7 +15010,7 @@ __metadata: languageName: unknown linkType: soft -"execa@npm:^5.1.1": +"execa@npm:^5.0.0, execa@npm:^5.1.1": version: 5.1.1 resolution: "execa@npm:5.1.1" dependencies: @@ -14630,7 +15182,7 @@ __metadata: languageName: node linkType: hard -"fast-glob@npm:^3.2.11, fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.0": +"fast-glob@npm:^3.2.11, fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.0, fast-glob@npm:^3.3.2": version: 3.3.3 resolution: "fast-glob@npm:3.3.3" dependencies: @@ -14664,6 +15216,17 @@ __metadata: languageName: node linkType: hard +"fast-xml-parser@npm:^4.4.1": + version: 4.5.7 + resolution: "fast-xml-parser@npm:4.5.7" + dependencies: + strnum: "npm:^1.0.5" + bin: + fxparser: src/cli/cli.js + checksum: 10c0/5fccf3f53d6b2b83143d73089f04ef5db5343422891193cf10d92d3eb856007b7337818494109e46f6fa47b31b9716be15c4b275ff87a0aeb6f7315aa2edc181 + languageName: node + linkType: hard + "fastest-levenshtein@npm:^1.0.12": version: 1.0.16 resolution: "fastest-levenshtein@npm:1.0.16" @@ -15263,7 +15826,7 @@ __metadata: languageName: node linkType: hard -"get-caller-file@npm:^2.0.5": +"get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5": version: 2.0.5 resolution: "get-caller-file@npm:2.0.5" checksum: 10c0/c6c7b60271931fa752aeb92f2b47e355eac1af3a2673f47c9589e8f8a41adc74d45551c1bc57b5e66a80609f10ffb72b6f575e4370d61cc3f7f3aaff01757cde @@ -15599,7 +16162,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.5, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": +"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.3, graceful-fs@npm:^4.1.5, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -16459,7 +17022,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.2.1": +"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb @@ -16955,6 +17518,13 @@ __metadata: languageName: node linkType: hard +"is-fullwidth-code-point@npm:^2.0.0": + version: 2.0.0 + resolution: "is-fullwidth-code-point@npm:2.0.0" + checksum: 10c0/e58f3e4a601fc0500d8b2677e26e9fe0cd450980e66adb29d85b6addf7969731e38f8e43ed2ec868a09c101a55ac3d8b78902209269f38c5286bc98f5bc1b4d9 + languageName: node + linkType: hard + "is-fullwidth-code-point@npm:^3.0.0": version: 3.0.0 resolution: "is-fullwidth-code-point@npm:3.0.0" @@ -17015,6 +17585,13 @@ __metadata: languageName: node linkType: hard +"is-interactive@npm:^1.0.0": + version: 1.0.0 + resolution: "is-interactive@npm:1.0.0" + checksum: 10c0/dd47904dbf286cd20aa58c5192161be1a67138485b9836d5a70433b21a45442e9611b8498b8ab1f839fc962c7620667a50535fdfb4a6bc7989b8858645c06b4d + languageName: node + linkType: hard + "is-interactive@npm:^2.0.0": version: 2.0.0 resolution: "is-interactive@npm:2.0.0" @@ -17326,6 +17903,13 @@ __metadata: languageName: node linkType: hard +"is-unicode-supported@npm:^0.1.0": + version: 0.1.0 + resolution: "is-unicode-supported@npm:0.1.0" + checksum: 10c0/00cbe3455c3756be68d2542c416cab888aebd5012781d6819749fefb15162ff23e38501fe681b3d751c73e8ff561ac09a5293eba6f58fdf0178462ce6dcb3453 + languageName: node + linkType: hard + "is-unicode-supported@npm:^1.1.0": version: 1.3.0 resolution: "is-unicode-supported@npm:1.3.0" @@ -17380,6 +17964,13 @@ __metadata: languageName: node linkType: hard +"is-wsl@npm:^1.1.0": + version: 1.1.0 + resolution: "is-wsl@npm:1.1.0" + checksum: 10c0/7ad0012f21092d6f586c7faad84755a8ef0da9b9ec295e4dc82313cce4e1a93a3da3c217265016461f9b141503fe55fa6eb1fd5457d3f05e8d1bdbb48e50c13a + languageName: node + linkType: hard + "is-wsl@npm:^2.1.1, is-wsl@npm:^2.2.0": version: 2.2.0 resolution: "is-wsl@npm:2.2.0" @@ -18135,7 +18726,7 @@ __metadata: languageName: node linkType: hard -"joi@npm:^17.9.2": +"joi@npm:^17.2.1, joi@npm:^17.9.2": version: 17.13.8 resolution: "joi@npm:17.13.8" dependencies: @@ -18495,7 +19086,7 @@ __metadata: languageName: node linkType: hard -"launch-editor@npm:^2.14.1": +"launch-editor@npm:^2.14.1, launch-editor@npm:^2.9.1": version: 2.14.1 resolution: "launch-editor@npm:2.14.1" dependencies: @@ -18704,6 +19295,16 @@ __metadata: languageName: node linkType: hard +"log-symbols@npm:^4.1.0": + version: 4.1.0 + resolution: "log-symbols@npm:4.1.0" + dependencies: + chalk: "npm:^4.1.0" + is-unicode-supported: "npm:^0.1.0" + checksum: 10c0/67f445a9ffa76db1989d0fa98586e5bc2fd5247260dafb8ad93d9f0ccd5896d53fb830b0e54dade5ad838b9de2006c826831a3c528913093af20dff8bd24aca6 + languageName: node + linkType: hard + "log-symbols@npm:^5.1.0": version: 5.1.0 resolution: "log-symbols@npm:5.1.0" @@ -18714,6 +19315,19 @@ __metadata: languageName: node linkType: hard +"logkitty@npm:^0.7.1": + version: 0.7.1 + resolution: "logkitty@npm:0.7.1" + dependencies: + ansi-fragments: "npm:^0.2.1" + dayjs: "npm:^1.8.15" + yargs: "npm:^15.1.0" + bin: + logkitty: bin/logkitty.js + checksum: 10c0/2067fad55c0856c0608c51ab75f8ffa5a858c5f847fefa8ec0e5fd3aa0b7d732010169d187283b23583a72aa6b80bbbec4fc6801a6c47c3fac0fbb294786002a + languageName: node + linkType: hard + "longest-streak@npm:^3.0.0": version: 3.1.0 resolution: "longest-streak@npm:3.1.0" @@ -20160,6 +20774,15 @@ __metadata: languageName: node linkType: hard +"mime@npm:^2.4.1": + version: 2.6.0 + resolution: "mime@npm:2.6.0" + bin: + mime: cli.js + checksum: 10c0/a7f2589900d9c16e3bdf7672d16a6274df903da958c1643c9c45771f0478f3846dcb1097f31eb9178452570271361e2149310931ec705c037210fc69639c8e6c + languageName: node + linkType: hard + "mimic-fn@npm:^2.1.0": version: 2.1.0 resolution: "mimic-fn@npm:2.1.0" @@ -20625,6 +21248,13 @@ __metadata: languageName: node linkType: hard +"nocache@npm:^3.0.1": + version: 3.0.4 + resolution: "nocache@npm:3.0.4" + checksum: 10c0/66e5db1206bee44173358c2264ae9742259273e9719535077fe27807441bad58f0deeadf3cec2aa62d4f86ccb8a0e067c9a64b6329684ddc30a57e377ec458ee + languageName: node + linkType: hard + "nock@npm:13.3.1": version: 13.3.1 resolution: "nock@npm:13.3.1" @@ -20737,6 +21367,13 @@ __metadata: languageName: node linkType: hard +"node-stream-zip@npm:^1.9.1": + version: 1.16.0 + resolution: "node-stream-zip@npm:1.16.0" + checksum: 10c0/3e201e39b42b46c2e543bd11c71821aa675a98d6cb4901840bd952281696599c9c29c630cd00e8da9bee4b02879fee2d0194a225b359d48a3bf966bdd63d8648 + languageName: node + linkType: hard + "noms@npm:0.0.0": version: 0.0.0 resolution: "noms@npm:0.0.0" @@ -21250,6 +21887,15 @@ __metadata: languageName: node linkType: hard +"open@npm:^6.2.0": + version: 6.4.0 + resolution: "open@npm:6.4.0" + dependencies: + is-wsl: "npm:^1.1.0" + checksum: 10c0/447115632b4f3939fa0d973c33e17f28538fd268fd8257fc49763f7de6e76d29d65585b15998bbd2137337cfb70a92084a0e1b183a466e53a4829f704f295823 + languageName: node + linkType: hard + "open@npm:^7.0.3": version: 7.4.2 resolution: "open@npm:7.4.2" @@ -21294,6 +21940,23 @@ __metadata: languageName: node linkType: hard +"ora@npm:^5.4.1": + version: 5.4.1 + resolution: "ora@npm:5.4.1" + dependencies: + bl: "npm:^4.1.0" + chalk: "npm:^4.1.0" + cli-cursor: "npm:^3.1.0" + cli-spinners: "npm:^2.5.0" + is-interactive: "npm:^1.0.0" + is-unicode-supported: "npm:^0.1.0" + log-symbols: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + wcwidth: "npm:^1.0.1" + checksum: 10c0/10ff14aace236d0e2f044193362b22edce4784add08b779eccc8f8ef97195cae1248db8ec1ec5f5ff076f91acbe573f5f42a98c19b78dba8c54eefff983cae85 + languageName: node + linkType: hard + "ora@npm:^6.3.1": version: 6.3.1 resolution: "ora@npm:6.3.1" @@ -24617,6 +25280,13 @@ __metadata: languageName: node linkType: hard +"require-main-filename@npm:^2.0.0": + version: 2.0.0 + resolution: "require-main-filename@npm:2.0.0" + checksum: 10c0/db91467d9ead311b4111cbd73a4e67fa7820daed2989a32f7023785a2659008c6d119752d9c4ac011ae07e537eb86523adff99804c5fdb39cd3a017f9b401bb6 + languageName: node + linkType: hard + "requires-port@npm:^1.0.0": version: 1.0.0 resolution: "requires-port@npm:1.0.0" @@ -24742,6 +25412,16 @@ __metadata: languageName: node linkType: hard +"restore-cursor@npm:^3.1.0": + version: 3.1.0 + resolution: "restore-cursor@npm:3.1.0" + dependencies: + onetime: "npm:^5.1.0" + signal-exit: "npm:^3.0.2" + checksum: 10c0/8051a371d6aa67ff21625fa94e2357bd81ffdc96267f3fb0fc4aaf4534028343836548ef34c240ffa8c25b280ca35eb36be00b3cb2133fa4f51896d7e73c6b4f + languageName: node + linkType: hard + "restore-cursor@npm:^4.0.0": version: 4.0.0 resolution: "restore-cursor@npm:4.0.0" @@ -25348,7 +26028,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.1.1, semver@npm:^7.1.3, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.7.4, semver@npm:^7.8.0, semver@npm:^7.8.4": +"semver@npm:^7.1.1, semver@npm:^7.1.3, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.7.4, semver@npm:^7.8.0, semver@npm:^7.8.4": version: 7.8.5 resolution: "semver@npm:7.8.5" bin: @@ -25399,6 +26079,27 @@ __metadata: languageName: node linkType: hard +"send@npm:~0.19.1": + version: 0.19.2 + resolution: "send@npm:0.19.2" + dependencies: + debug: "npm:2.6.9" + depd: "npm:2.0.0" + destroy: "npm:1.2.0" + encodeurl: "npm:~2.0.0" + escape-html: "npm:~1.0.3" + etag: "npm:~1.8.1" + fresh: "npm:~0.5.2" + http-errors: "npm:~2.0.1" + mime: "npm:1.6.0" + ms: "npm:2.1.3" + on-finished: "npm:~2.4.1" + range-parser: "npm:~1.2.1" + statuses: "npm:~2.0.2" + checksum: 10c0/20c2389fe0fdf3fc499938cac598bc32272287e993c4960717381a10de8550028feadfb9076f959a3a3ebdea42e1f690e116f0d16468fa56b9fd41866d3dc267 + languageName: node + linkType: hard + "serialize-error@npm:^2.1.0": version: 2.1.0 resolution: "serialize-error@npm:2.1.0" @@ -25443,6 +26144,18 @@ __metadata: languageName: node linkType: hard +"serve-static@npm:^1.13.1": + version: 1.16.3 + resolution: "serve-static@npm:1.16.3" + dependencies: + encodeurl: "npm:~2.0.0" + escape-html: "npm:~1.0.3" + parseurl: "npm:~1.3.3" + send: "npm:~0.19.1" + checksum: 10c0/36320397a073c71bedf58af48a4a100fe6d93f07459af4d6f08b9a7217c04ce2a4939e0effd842dc7bece93ffcd59eb52f58c4fff2a8e002dc29ae6b219cd42b + languageName: node + linkType: hard + "serve-static@npm:^1.16.2, serve-static@npm:~1.16.2": version: 1.16.2 resolution: "serve-static@npm:1.16.2" @@ -25476,6 +26189,13 @@ __metadata: languageName: node linkType: hard +"set-blocking@npm:^2.0.0": + version: 2.0.0 + resolution: "set-blocking@npm:2.0.0" + checksum: 10c0/9f8c1b2d800800d0b589de1477c753492de5c1548d4ade52f57f1d1f5e04af5481554d75ce5e5c43d4004b80a3eb714398d6907027dc0534177b7539119f4454 + languageName: node + linkType: hard + "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -25819,6 +26539,17 @@ __metadata: languageName: node linkType: hard +"slice-ansi@npm:^2.0.0": + version: 2.1.0 + resolution: "slice-ansi@npm:2.1.0" + dependencies: + ansi-styles: "npm:^3.2.0" + astral-regex: "npm:^1.0.0" + is-fullwidth-code-point: "npm:^2.0.0" + checksum: 10c0/c317b21ec9e3d3968f3d5b548cbfc2eae331f58a03f1352621020799cbe695b3611ee972726f8f32d4ca530065a5ec9c74c97fde711c1f41b4a1585876b2c191 + languageName: node + linkType: hard + "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -26403,6 +27134,15 @@ __metadata: languageName: node linkType: hard +"strip-ansi@npm:^5.0.0": + version: 5.2.0 + resolution: "strip-ansi@npm:5.2.0" + dependencies: + ansi-regex: "npm:^4.1.0" + checksum: 10c0/de4658c8a097ce3b15955bc6008f67c0790f85748bdc025b7bc8c52c7aee94bc4f9e50624516150ed173c3db72d851826cd57e7a85fe4e4bb6dbbebd5d297fdf + languageName: node + linkType: hard + "strip-ansi@npm:^7.0.1": version: 7.1.2 resolution: "strip-ansi@npm:7.1.2" @@ -26496,6 +27236,13 @@ __metadata: languageName: node linkType: hard +"strnum@npm:^1.0.5": + version: 1.1.2 + resolution: "strnum@npm:1.1.2" + checksum: 10c0/a0fce2498fa3c64ce64a40dada41beb91cabe3caefa910e467dc0518ef2ebd7e4d10f8c2202a6104f1410254cae245066c0e94e2521fb4061a5cb41831952392 + languageName: node + linkType: hard + "style-to-object@npm:^0.4.0": version: 0.4.4 resolution: "style-to-object@npm:0.4.4" @@ -28524,6 +29271,13 @@ __metadata: languageName: node linkType: hard +"which-module@npm:^2.0.0": + version: 2.0.1 + resolution: "which-module@npm:2.0.1" + checksum: 10c0/087038e7992649eaffa6c7a4f3158d5b53b14cf5b6c1f0e043dccfacb1ba179d12f17545d5b85ebd94a42ce280a6fe65d0cbcab70f4fc6daad1dfae85e0e6a3e + languageName: node + linkType: hard + "which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.19, which-typed-array@npm:^1.1.2": version: 1.1.19 resolution: "which-typed-array@npm:1.1.19" @@ -28710,6 +29464,15 @@ __metadata: languageName: node linkType: hard +"ws@npm:^6.2.3": + version: 6.2.6 + resolution: "ws@npm:6.2.6" + dependencies: + async-limiter: "npm:~1.0.0" + checksum: 10c0/24245efc3c09b6175df32ddb4a06f85432c4c88abb0e75f43f2551c1d5de456218b1b6c7685c414b8e03a68a167488e4aadf04a895681c8e01502025ce505742 + languageName: node + linkType: hard + "ws@npm:^7, ws@npm:^7.3.1, ws@npm:^7.5.10": version: 7.5.13 resolution: "ws@npm:7.5.13" @@ -28788,6 +29551,13 @@ __metadata: languageName: node linkType: hard +"y18n@npm:^4.0.0": + version: 4.0.3 + resolution: "y18n@npm:4.0.3" + checksum: 10c0/308a2efd7cc296ab2c0f3b9284fd4827be01cfeb647b3ba18230e3a416eb1bc887ac050de9f8c4fd9e7856b2e8246e05d190b53c96c5ad8d8cb56dffb6f81024 + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.5 resolution: "y18n@npm:5.0.5" @@ -28823,6 +29593,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.2.1": + version: 2.9.1 + resolution: "yaml@npm:2.9.1" + bin: + yaml: bin.mjs + checksum: 10c0/9d06676ef02b89559a8b10a71882bd2cdae81e8393dc8dbc871615c3259475d9609ad973d19cb3c9e71e72d6206e3d1e7aa241c7e55f3d16fd2e77db305827d6 + languageName: node + linkType: hard + "yaml@npm:^2.6.1": version: 2.9.0 resolution: "yaml@npm:2.9.0" @@ -28832,6 +29611,16 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^18.1.2": + version: 18.1.3 + resolution: "yargs-parser@npm:18.1.3" + dependencies: + camelcase: "npm:^5.0.0" + decamelize: "npm:^1.2.0" + checksum: 10c0/25df918833592a83f52e7e4f91ba7d7bfaa2b891ebf7fe901923c2ee797534f23a176913ff6ff7ebbc1cc1725a044cc6a6539fed8bfd4e13b5b16376875f9499 + languageName: node + linkType: hard + "yargs-parser@npm:^20.2.2": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" @@ -28846,6 +29635,25 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^15.1.0": + version: 15.4.1 + resolution: "yargs@npm:15.4.1" + dependencies: + cliui: "npm:^6.0.0" + decamelize: "npm:^1.2.0" + find-up: "npm:^4.1.0" + get-caller-file: "npm:^2.0.1" + require-directory: "npm:^2.1.1" + require-main-filename: "npm:^2.0.0" + set-blocking: "npm:^2.0.0" + string-width: "npm:^4.2.0" + which-module: "npm:^2.0.0" + y18n: "npm:^4.0.0" + yargs-parser: "npm:^18.1.2" + checksum: 10c0/f1ca680c974333a5822732825cca7e95306c5a1e7750eb7b973ce6dc4f97a6b0a8837203c8b194f461969bfe1fb1176d1d423036635285f6010b392fa498ab2d + languageName: node + linkType: hard + "yargs@npm:^16.1.0": version: 16.2.0 resolution: "yargs@npm:16.2.0"